diff --git a/.codeclimate.yml b/.codeclimate.yml new file mode 100644 index 00000000000..1c16231e78c --- /dev/null +++ b/.codeclimate.yml @@ -0,0 +1,17 @@ +version: "2" +exclude_patterns: +- "config/" +- "db/" +- "dist/" +- "features/" +- "**/node_modules/" +- "script/" +- "**/spec/" +- "**/test/" +- "**/tests/" +- "**/vendor/" +- "**/*.d.ts" +- "app/models/user.rb" +method-count: + config: + threshold: 25 diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000000..8106685b3e7 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,8 @@ +# syntax = docker/dockerfile:1.4 + +ARG RUBY_VERSION=3.3.5 +FROM ghcr.io/rails/devcontainer/images/ruby:$RUBY_VERSION + +RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ + && apt-get -y install --no-install-recommends pkg-config \ + && apt-get clean && rm -rf /var/lib/apt/lists/* diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000000..d05adf8a1e8 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,55 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +{ + "name": "RubyGems.org", + "dockerComposeFile": [ + "docker-compose.yml", + "../docker-compose.yml" + ], + "service": "rails-app", + "runServices": [ + "db", + "cache", + "search", + "toxiproxy", + "selenium" + ], + "forwardPorts": [ + 3000, // Rails + 11211, // Memcache + 9200, // Opensearch + 5432 // PostgreSQL + ], + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + // Use 'postCreateCommand' to run commands after the container is created. + "onCreateCommand": "bin/setup", + // Use 'updateContentCommand' to run commands when the container is updated. + "updateContentCommand": "bin/setup", + // Features to add to the dev container. More info: https://containers.dev/features. + "features": { + "ghcr.io/devcontainers/features/github-cli:1": {}, + // "ghcr.io/rails/devcontainer/features/activestorage": {}, + "ghcr.io/rails/devcontainer/features/postgres-client": {} + }, + // Configure tool-specific properties. + "containerEnv": { + "EDITOR": "code --wait", + "GIT_EDITOR": "code --wait", + "CAPYBARA_SERVER_PORT": "45678", + "SELENIUM_HOST": "selenium", + "ELASTICSEARCH_URL": "http://search:9200", + "DATABASE_URL": "postgres://postgres@db:5432" + }, + "customizations": { + "codespaces": { + "openFiles": [ + "README.md", + "CONTRIBUTING.md" + ] + }, + "vscode": { + "extensions": [ + "Shopify.ruby-lsp" + ] + } + } +} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 00000000000..9607a18c49f --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,16 @@ +services: + rails-app: + build: + context: . + dockerfile: Dockerfile + command: sleep infinity + volumes: + - ../..:/workspaces:cached + depends_on: + - search + - db + - selenium + + selenium: + image: selenium/standalone-chromium + restart: unless-stopped diff --git a/.dockerignore b/.dockerignore index 8eb3439dbb0..89532816e61 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,44 @@ +# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files. + +# Ignore git directory. +/.git/ + +# Ignore bundler config. +/.bundle + +# Ignore all default key files. +/config/master.key +/config/credentials/*.key + +# Ignore all environment files. +/.env* +!/.env.example + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +/reports/* + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* + +# Ignore assets. +/node_modules/ +/app/assets/builds/* +!/app/assets/builds/.keep +/public/assets + +# Ignore tests +/test/ + +# Ignore files that are generated by the build process. +# /config/database.yml.sample +/config/versions.list +/vendor/toxic_domains_whole.txt + +# From before .git* +.github* log/* tmp/* doc/* @@ -6,11 +46,13 @@ server/* vendor/cache/* config/deploy/* Dockerfile +script/build_docker.sh .gitignore .gitmodules -.travis.yml CONDUCT.md CONTRIBUTING.md MIT-LICENSE README.md shipit.yml +coverage/ +REVISION diff --git a/.erdconfig b/.erdconfig new file mode 100644 index 00000000000..3d5dd4fc4b8 --- /dev/null +++ b/.erdconfig @@ -0,0 +1,4 @@ +fonts: + normal: "Arial" + bold: "Arial Bold" + italic: "Arial Italic" \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 1b840173194..5f9e66dab7c 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1,2 @@ -custom: [rubytogether.org, rubycentral.org] +custom: https://rubycentral.org/#/portal/signup +github: rubytogether diff --git a/.github/actions/setup-rubygems.org/action.yml b/.github/actions/setup-rubygems.org/action.yml new file mode 100644 index 00000000000..e8c11d1da90 --- /dev/null +++ b/.github/actions/setup-rubygems.org/action.yml @@ -0,0 +1,29 @@ +name: "Setup rubygems.org" +description: "Setup steps for rubygems.org" +inputs: + ruby-version: + description: "Ruby version to use" + required: true + rubygems-version: + description: "RubyGems version to use" + required: true +runs: + using: "composite" + steps: + - name: Install and start services + shell: bash + run: | + docker compose up -d --wait + - uses: ruby/setup-ruby@52753b7da854d5c07df37391a986c76ab4615999 # v1.191.0 + with: + ruby-version: ${{ inputs.ruby-version }} + bundler-cache: true + rubygems: ${{ inputs.rubygems-version }} + - name: Print bundle environment + shell: bash + run: bundle env + - name: Prepare environment + shell: bash + run: | + cp config/database.yml.sample config/database.yml + bundle exec rake db:setup assets:precompile diff --git a/.github/codecov.yml b/.github/codecov.yml new file mode 100644 index 00000000000..4dde336da39 --- /dev/null +++ b/.github/codecov.yml @@ -0,0 +1,3 @@ +codecov: + notify: + after_n_builds: 2 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 202c5e9535a..31c3ad24e5f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,9 +5,15 @@ updates: schedule: interval: daily open-pull-requests-limit: 99 - versioning-strategy: lockfile-only + versioning-strategy: increase ignore: - - dependency-name: aws-sdk - versions: - - "> 2.11.473, < 2.12" - - dependency-name: "rubocop*" \ No newline at end of file + - dependency-name: "rubocop*" +- package-ecosystem: github-actions + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 99 +- package-ecosystem: docker + directory: / + schedule: + interval: daily diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000000..9ef36f5d78e --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,76 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: ["master"] + pull_request: + # The branches below must be a subset of the branches above + branches: ["master"] + schedule: + - cron: "0 0 * * 1" + +permissions: # added using https://github.com/step-security/secure-workflows + contents: read + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-24.04 + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: ["javascript", "ruby"] + # CodeQL supports [ $supported-codeql-languages ] + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@c36620d31ac7c881962c3d9dd939c40ec9434f2b # v3.26.12 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@c36620d31ac7c881962c3d9dd939c40ec9434f2b # v3.26.12 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@c36620d31ac7c881962c3d9dd939c40ec9434f2b # v3.26.12 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 00000000000..d381b4d8c76 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,50 @@ +name: Docker +on: + pull_request: + push: + branches: + - master + - oidc-api-tokens +permissions: + contents: read + id-token: write + +jobs: + build: + name: Docker build (and optional push) + runs-on: ubuntu-24.04 + env: + RUBYGEMS_VERSION: "3.5.20" + RUBY_VERSION: "3.3.5" + steps: + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # master + - name: Cache Docker layers + uses: actions/cache@3624ceb22c1c5a301c8db4169662070a689d9ea8 # v4.1.1 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-rubygems-${{ hashFiles('**/Gemfile.lock') }} + restore-keys: | + ${{ runner.os }}-rubygems-org + - name: Install and start services (needed for image test) + run: docker compose up -d + - name: Configure AWS credentials from Production account + uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 + if: github.secret_source != 'None' + with: + role-to-assume: arn:aws:iam::048268392960:role/rubygems-ecr-pusher + aws-region: us-west-2 + - name: Login to Amazon ECR + if: github.secret_source != 'None' + uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076 # v2.0.1 + - name: build, test and optionally push docker image + run: ./script/build_docker.sh + # Temp fix + # https://github.com/docker/build-push-action/issues/252 + # https://github.com/moby/buildkit/issues/1896 + - name: Move cache + run: | + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000000..4fe3dddb771 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,86 @@ +name: Lint +on: + pull_request: + push: + branches: + - master +permissions: + contents: read + +jobs: + rubocop: + name: Rubocop + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + - uses: ruby/setup-ruby@f26937343756480a8cb3ae1f623b9c8d89ed6984 # v1.196.0 + with: + bundler-cache: true + - name: Rubocop + run: bundle exec rubocop + brakeman: + name: Brakeman + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + - uses: ruby/setup-ruby@f26937343756480a8cb3ae1f623b9c8d89ed6984 # v1.196.0 + with: + bundler-cache: true + - name: Brakeman + run: bundle exec brakeman + importmap: + name: Importmap Verify + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + - uses: ruby/setup-ruby@f26937343756480a8cb3ae1f623b9c8d89ed6984 # v1.196.0 + with: + bundler-cache: true + - name: Importmap Verify + run: bundle exec rake importmap:verify + kubeconform: + name: Kubeconform + runs-on: ubuntu-24.04 + strategy: + matrix: + kubernetes_version: ["1.29.1"] + environment: + - staging + - production + steps: + - name: login to Github Packages + run: echo "${{ github.token }}" | docker login https://ghcr.io -u ${GITHUB_ACTOR} --password-stdin + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + - uses: ruby/setup-ruby@f26937343756480a8cb3ae1f623b9c8d89ed6984 # v1.196.0 + with: + bundler-cache: true + - name: krane render + run: | + gem exec --silent krane render -f config/deploy/$ENVIRONMENT --bindings=environment=$ENVIRONMENT --current-sha=$REVISION > config/deploy/$ENVIRONMENT.rendered.yaml + env: + ENVIRONMENT: "${{ matrix.environment }}" + REVISION: "${{ github.sha }}" + - uses: actions/upload-artifact@84480863f228bb9747b473957fcc9e309aa96097 # v4.4.2 + with: + name: "${{ matrix.environment }}.rendered.yaml" + path: "config/deploy/${{ matrix.environment }}.rendered.yaml" + - name: kubeconform + uses: docker://ghcr.io/yannh/kubeconform@sha256:03f6b236ef64f20b4bc950209d6254b109e23b4b05e7811649f59eae5659fa58 # v0.6.3 + with: + entrypoint: "/kubeconform" + args: "-strict -summary -output json --kubernetes-version ${{ matrix.kubernetes_version }} config/deploy/${{ matrix.environment }}.rendered.yaml" + frizbee: + name: Frizbee + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + - uses: segiddins/frizbee-action@c162fdaa6c73525a577d2d6eb193683dfc9ba2be # segiddins/run-in-place + env: + GITHUB_TOKEN: ${{ github.token }} + with: + action_paths: '[".github/workflows", ".github/actions"]' + dockerfiles: '["./Dockerfile", ".devcontainer/Dockerfile"]' + docker_compose: '["./docker-compose.yml", ".devcontainer/docker-compose.yml"]' + fail_on_unpinned: true + open_pr: false + repo_root: "." diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml new file mode 100644 index 00000000000..c066180547b --- /dev/null +++ b/.github/workflows/scorecards.yml @@ -0,0 +1,72 @@ +# This workflow uses actions that are not certified by GitHub. They are provided +# by a third-party and are governed by separate terms of service, privacy +# policy, and support documentation. + +name: Scorecards supply-chain security +on: + # For Branch-Protection check. Only the default branch is supported. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection + branch_protection_rule: + # To guarantee Maintained check is occasionally updated. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained + schedule: + - cron: '22 4 * * 2' + # push: + # branches: [ "master" ] + +# Declare default permissions as read only. +permissions: read-all + +jobs: + analysis: + name: Scorecards analysis + runs-on: ubuntu-24.04 + permissions: + # Needed to upload the results to code-scanning dashboard. + security-events: write + # Needed to publish results and get a badge (see publish_results below). + id-token: write + # Uncomment the permissions below if installing in a private repository. + # contents: read + # actions: read + + steps: + - name: "Checkout code" + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v3.1.0 + with: + persist-credentials: false + + - name: "Run analysis" + uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0 + with: + results_file: results.sarif + results_format: sarif + # (Optional) Read-only PAT token. Uncomment the `repo_token` line below if: + # - you want to enable the Branch-Protection check on a *public* repository, or + # - you are installing Scorecards on a *private* repository + # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat. + repo_token: ${{ secrets.SCORECARD_READ_TOKEN }} + + # Public repositories: + # - Publish results to OpenSSF REST API for easy access by consumers + # - Allows the repository to include the Scorecard badge. + # - See https://github.com/ossf/scorecard-action#publishing-results. + # For private repositories: + # - `publish_results` will always be set to `false`, regardless + # of the value entered here. + publish_results: true + + # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF + # format to the repository Actions tab. + # - name: "Upload artifact" + # uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2 + # with: + # name: SARIF file + # path: results.sarif + # retention-days: 5 + + # Upload the results to GitHub's code scanning dashboard. + - name: "Upload to code-scanning" + uses: github/codeql-action/upload-sarif@c36620d31ac7c881962c3d9dd939c40ec9434f2b # v3.26.12 + with: + sarif_file: results.sarif diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000000..4d7b82775e2 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,69 @@ +name: Tests +on: + pull_request: + push: + branches: + - master +permissions: + contents: read + +jobs: + # This is umbrella job for all tests needed to pass to make it simpler + # to maintain GitHub Actions status required checks since job names and + # amount change over the time and it is easier to maintian having just + # this umbrella check set as required. + status_check: + name: All required tests passing check + needs: [rails] + runs-on: ubuntu-24.04 + if: always() + steps: + - run: /bin/${{ (needs.rails.result == 'success' || needs.rails.result == 'skipped') }} + + rails: + strategy: + fail-fast: false + matrix: + rubygems: + - name: locked + version: "3.5.20" + - name: latest + version: latest + ruby_version: ["3.3.5"] + tests: + - name: general + command: test + - name: system + command: test:system + name: Rails tests ${{ matrix.tests.name }} (RubyGems ${{ matrix.rubygems.name }}, Ruby ${{ matrix.ruby_version }}) + runs-on: ubuntu-22.04 + env: + RUBYGEMS_VERSION: ${{ matrix.rubygems.version }} + # Fail hard when Toxiproxy is not running to ensure all tests (even Toxiproxy optional ones) are passing + REQUIRE_TOXIPROXY: true + steps: + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + + - name: Setup rubygems.org + uses: ./.github/actions/setup-rubygems.org + with: + ruby-version: ${{ matrix.ruby_version }} + rubygems-version: ${{ matrix.rubygems.version }} + + - name: Tests ${{ matrix.tests.name }} + id: test-all + run: bin/rails ${{ matrix.tests.command }} + + - name: Save capybara screenshots + if: ${{ failure() && steps.test-all.outcome == 'failure' }} + uses: actions/upload-artifact@84480863f228bb9747b473957fcc9e309aa96097 # v4.4.2 + with: + name: capybara-screenshots-${{ matrix.tests.name }}-${{ matrix.rubygems.name }} + path: tmp/capybara + if-no-files-found: ignore + + - name: Upload coverage to Codecov + if: matrix.rubygems.name == 'locked' && (success() || failure()) + uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4.6.0 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index cc67ba36b0d..fcfb8481101 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,10 @@ chef public/assets latest_dump coverage +REVISION +/.env* +/.pumaenv.local +/doc/erd.* + +/app/assets/builds/* +!/app/assets/builds/.keep diff --git a/.pumaenv b/.pumaenv new file mode 100644 index 00000000000..57afa2d530a --- /dev/null +++ b/.pumaenv @@ -0,0 +1 @@ +[[ -e ".pumaenv.local" ]] && source .pumaenv.local diff --git a/.rubocop.yml b/.rubocop.yml index 47d1a4415fd..f7a301544aa 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,25 +1,25 @@ inherit_from: .rubocop_todo.yml require: - - ./test/safe_navigation_cop.rb + - ./lib/cops/safe_navigation_cop.rb - rubocop-performance - rubocop-rails + - rubocop-minitest + - rubocop-capybara + - rubocop-factory_bot AllCops: Exclude: - config/initializers/forbidden_yaml.rb - app/helpers/dynamic_errors_helper.rb - - !ruby/regexp /(vendor|bundle|bin|db|tmp)\/.*/ + - !ruby/regexp /(vendor|bundle|bin|db/(migrate/|schema\.rb|downloads_schema\.rb)|tmp|server)($|\/.*)/ DisplayCopNames: true DisplayStyleGuide: true - TargetRubyVersion: 2.6 + TargetRubyVersion: 3.3 + NewCops: enable Rails: Enabled: true -# TODO: Enable on rubocop 0.67.3 -Rails/ActiveRecordOverride: - Enabled: false - Rails/SkipsModelValidations: Enabled: false @@ -35,11 +35,6 @@ Rails/HelperInstanceVariable: Rails/FilePath: Enabled: false -Rails/RakeEnvironment: - Exclude: - - lib/tasks/gen_erd.rake - - lib/tasks/elasticsearch.rake - Rails/UnknownEnv: Environments: - production @@ -50,6 +45,14 @@ Rails/UnknownEnv: Rails/UniqueValidationWithoutIndex: Enabled: false +Rails/ActionControllerTestCase: + Enabled: false # Causes every integration test to fail + +Rails/Output: + Exclude: + - app/views/**/*_view.rb + - app/views/**/*_component.rb + Layout/ArgumentAlignment: Enabled: false @@ -57,11 +60,23 @@ Metrics/AbcSize: Max: 42 # TODO: Lower to 15 Exclude: - test/functional/api/v1/owners_controller_test.rb + - db/**/*.rb -Metrics/ClassLength: - Max: 350 # TODO: Lower to 100 +Metrics/BlockLength: Exclude: - test/**/*.rb + - lib/tasks/**/*.rake + - lib/github_oauthable.rb + - app/models/concerns/**/*.rb + - config/routes.rb + - config/environments/development.rb + - db/**/*.rb + +Metrics/ClassLength: + Max: 357 # TODO: Lower to 100 + Exclude: + - test/**/* + - db/**/*.rb Metrics/CyclomaticComplexity: Max: 9 # TODO: Lower to 6 @@ -71,6 +86,8 @@ Layout/LineLength: Metrics/MethodLength: Max: 30 # TODO: Lower to 10 + Exclude: + - db/**/*.rb Metrics/ModuleLength: Exclude: @@ -81,11 +98,6 @@ Metrics/ModuleLength: Metrics/PerceivedComplexity: Max: 10 # TODO: Lower to 7 -# TODO: Enable on rubocop 0.67.3 -Naming/RescuedExceptionsVariableName: - Exclude: - - app/jobs/notifier.rb - Performance/RedundantMerge: Enabled: false @@ -122,14 +134,12 @@ Style/CustomSafeNavigationCop: Exclude: - app/models/links.rb -Style/ClassAndModuleChildren: - EnforcedStyle: compact - Exclude: - - lib/lograge/formatters/datadog.rb - Style/Documentation: Enabled: false +Style/FetchEnvVar: + Enabled: false + Style/StringLiterals: EnforcedStyle: double_quotes Exclude: @@ -138,9 +148,6 @@ Style/StringLiterals: Style/FrozenStringLiteralComment: Enabled: false -Security/MarshalLoad: - Enabled: false - Style/EmptyMethod: EnforcedStyle: expanded @@ -270,8 +277,26 @@ Style/HashAsLastArrayItem: Style/HashLikeCase: Enabled: true +Style/HashSyntax: + EnforcedShorthandSyntax: either + Style/RedundantAssignment: Enabled: true Style/RedundantFileExtensionInRequire: - Enabled: true \ No newline at end of file + Enabled: true + +Minitest/MultipleAssertions: + Enabled: false + +Style/ClassAndModuleChildren: + EnforcedStyle: compact + Exclude: + - lib/gemcutter/middleware/hostess.rb + - lib/gemcutter/middleware/redirector.rb + +Capybara/ClickLinkOrButtonStyle: + Enabled: false + +Rails/ThreeStateBooleanColumn: + Enabled: false diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 26951cb1048..995a27e1011 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,14 +1,15 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2016-12-03 09:18:45 +0000 using RuboCop version 0.46.0. +# on 2024-07-03 00:52:11 UTC using RuboCop version 1.60.1. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. -# Offense count: 8 -# Configuration parameters: Include. -# Include: **/Gemfile, **/gems.rb +# Offense count: 30 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: TreatCommentsAsGroupSeparators, ConsiderPunctuation, Include. +# Include: **/*.gemfile, **/Gemfile, **/gems.rb Bundler/OrderedGems: Exclude: - 'Gemfile' @@ -19,38 +20,106 @@ Lint/DuplicateMethods: - 'test/functional/api/v1/downloads_controller_test.rb' - 'test/functional/api/v1/rubygems_controller_test.rb' -# Offense count: 89 -# Configuration parameters: CountComments. +# Offense count: 1 +# This cop supports unsafe autocorrection (--autocorrect-all). +Lint/UselessMethodDefinition: + Exclude: + - 'config/initializers/gem_version_monkeypatch.rb' + +# Offense count: 16 +# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. +# AllowedMethods: refine Metrics/BlockLength: - Max: 284 + Max: 61 +# Offense count: 1 +# Configuration parameters: AllowedMethods, AllowedPatterns, Max. Metrics/CyclomaticComplexity: Exclude: - 'app/models/concerns/rubygem_searchable.rb' -# Offense count: 43 -# Cop supports --auto-correct. -# Configuration parameters: Whitelist. -# Whitelist: find_by_sql +# Offense count: 1 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: EnforcedStyleForLeadingUnderscores. +# SupportedStylesForLeadingUnderscores: disallowed, required, optional +Naming/MemoizedInstanceVariableName: + Exclude: + - 'lib/rubygem_fs.rb' + +# Offense count: 16 +# Configuration parameters: EnforcedStyle, CheckMethodNames, CheckSymbols, AllowedIdentifiers, AllowedPatterns. +# SupportedStyles: snake_case, normalcase, non_integer +# AllowedIdentifiers: capture3, iso8601, rfc1123_date, rfc822, rfc2822, rfc3339, x86_64 +Naming/VariableNumber: + Exclude: + - 'test/functional/api/v1/downloads_controller_test.rb' + - 'test/models/concerns/rubygem_searchable_test.rb' + - 'test/models/gem_download_test.rb' + +# Offense count: 1 +# Configuration parameters: Database, Include. +# SupportedDatabases: mysql, postgresql +# Include: db/**/*.rb +Rails/BulkChangeTable: + Exclude: + - 'db/migrate/20240522185716_create_good_job_process_lock_ids.rb' + +# Offense count: 83 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: Whitelist, AllowedMethods, AllowedReceivers. +# Whitelist: find_by_sql, find_by_token_for +# AllowedMethods: find_by_sql, find_by_token_for +# AllowedReceivers: Gem::Specification, page Rails/DynamicFindBy: Enabled: false -# Offense count: 119 -# Cop supports --auto-correct. +# Offense count: 6 # Configuration parameters: Include. -# Include: spec/**/*, test/**/* -Rails/HttpPositionalArguments: - Enabled: false +# Include: app/models/**/*.rb +Rails/HasManyOrHasOneDependent: + Exclude: + - 'app/models/rubygem.rb' + +# Offense count: 1 +# Configuration parameters: Include. +# Include: spec/**/*.rb, test/**/*.rb +Rails/I18nLocaleAssignment: + Exclude: + - 'test/test_helper.rb' + +# Offense count: 7 +Rails/I18nLocaleTexts: + Exclude: + - 'app/mailers/mailer.rb' + - 'app/mailers/owners_mailer.rb' + - 'app/models/ownership_call.rb' + - 'app/models/user.rb' -# Offense count: 4 +# Offense count: 5 Rails/OutputSafety: Exclude: - 'app/helpers/application_helper.rb' - 'app/helpers/rubygems_helper.rb' -# Offense count: 38 -# Cop supports --auto-correct. -# Configuration parameters: AutoCorrect. +# Offense count: 6 +# This cop supports unsafe autocorrection (--autocorrect-all). +Rails/RedundantPresenceValidationOnBelongsTo: + Exclude: + - 'app/models/api_key_rubygem_scope.rb' + - 'app/models/deletion.rb' + - 'app/models/ownership_call.rb' + - 'app/models/ownership_request.rb' + - 'app/models/subscription.rb' + - 'app/models/version.rb' + +# Offense count: 1 +# This cop supports unsafe autocorrection (--autocorrect-all). +Rails/RelativeDateConstant: + Exclude: + - 'app/models/gem_typo.rb' + +# Offense count: 39 +# This cop supports unsafe autocorrection (--autocorrect-all). Security/JSONLoad: Exclude: - 'config/initializers/yaml_renderer.rb' @@ -63,72 +132,23 @@ Security/JSONLoad: - 'test/functional/api/v1/web_hooks_controller_test.rb' - 'test/functional/api/v2/versions_controller_test.rb' - 'test/integration/api/v2/version_information_test.rb' - - 'test/unit/dependency_test.rb' - - 'test/unit/rubygem_test.rb' - - 'test/unit/web_hook_test.rb' + - 'test/models/dependency_test.rb' + - 'test/models/rubygem_test.rb' + - 'test/models/web_hook_test.rb' # Offense count: 2 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles. -# SupportedStyles: compact, expanded -Style/EmptyMethod: - Exclude: - - 'app/controllers/email_confirmations_controller.rb' - - 'app/controllers/rubygems_controller.rb' - -# Offense count: 5 -# Cop supports --auto-correct. -# Configuration parameters: AllowForAlignment, ForceEqualSignAlignment. -Layout/ExtraSpacing: - Exclude: - - 'script/s3_utils.rb' - - 'test/unit/version_test.rb' - -Naming/MemoizedInstanceVariableName: - Exclude: - - 'lib/rubygem_fs.rb' - -# Offense count: 2 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedOctalStyle, SupportedOctalStyles. +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedOctalStyle. # SupportedOctalStyles: zero_with_o, zero_only Style/NumericLiteralPrefix: Exclude: - - 'test/unit/pusher_test.rb' + - 'test/models/pusher_test.rb' -# Offense count: 4 -# Cop supports --auto-correct. -# Configuration parameters: AutoCorrect, EnforcedStyle, SupportedStyles. +# Offense count: 3 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: EnforcedStyle, AllowedMethods, AllowedPatterns. # SupportedStyles: predicate, comparison Style/NumericPredicate: Exclude: - - 'spec/**/*' - 'app/helpers/searches_helper.rb' - 'app/models/rubygem.rb' - -# Offense count: 1 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles, AllowSafeAssignment. -# SupportedStyles: require_parentheses, require_no_parentheses, require_parentheses_when_complex -Style/TernaryParentheses: - Exclude: - - 'config/initializers/clearance.rb' - -# Offense count: 16 -# Configuration parameters: EnforcedStyle, SupportedStyles. -# SupportedStyles: snake_case, normalcase, non_integer -Naming/VariableNumber: - Exclude: - - 'test/functional/api/v1/downloads_controller_test.rb' - - 'test/unit/gem_download_test.rb' - - 'test/unit/rubygem_searchable_test.rb' - -# Offense count: 2 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle. -# SupportedStyles: implicit, explicit -Style/RescueStandardError: - Exclude: - - 'app/controllers/api/v1/rubygems_controller.rb' - - 'app/jobs/fastly_log_processor.rb' - - 'lib/tasks/linkset.rake' diff --git a/.ruby-version b/.ruby-version index fbafd6b600a..fa7adc7ac72 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.7.2 \ No newline at end of file +3.3.5 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 7fddd35d18d..00000000000 --- a/.travis.yml +++ /dev/null @@ -1,58 +0,0 @@ -language: ruby - -cache: - bundler: true - directories: - - reports - - vendor/cache - -rvm: - - 2.7.2 - - ruby-head - -addons: - apt: - sources: - - elasticsearch-5.x - packages: - - elasticsearch - - openjdk-8-jre-headless - postgresql: "9.6" - chrome: stable - -services: - - elasticsearch - - memcached - - docker - -bundler_args: --jobs=3 --retry=3 --without development - -git: - submodules: true - -before_install: - - git submodule update --init - - sh -c "if [ '$RUBYGEMS_VERSION' != 'latest' ]; then gem update --system $RUBYGEMS_VERSION; fi" - - gem --version - - bundle -v - - script/install_toxiproxy.sh - -before_script: - - cp config/database.yml.example config/database.yml - - bundle exec rake db:setup - -script: - - bundle exec rails test - - bundle exec rake rubocop - - bundle exec brakeman - - script/build_docker.sh - -env: - - RUBYGEMS_VERSION=3.1.5 - - RUBYGEMS_VERSION=latest - -matrix: - allow_failures: - - env: "RUBYGEMS_VERSION=latest" - - rvm: ruby-head - fast_finish: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 52235ddd4fb..9f6b28a8dac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,45 +52,57 @@ gem server, please consider checking out provide pass-through caching for RubyGems.org, as well as host private gems for your organization..** +### Getting the code + +Clone the repo: `git clone https://github.com/rubygems/rubygems.org.git` +Move to the newly cloned repository directory: `cd rubygems.org` + +### Setting up the environment + +Rubygems.org is a Ruby on Rails application. +The app depends on Elasticsearch, Memcached, and PostgreSQL. +Google Chrome is used for tests. + +Setup the development environment using one of the approaches below. + #### Environment (Docker) + There is a `docker-compose.yml` file inside the project that easily lets you spin up -services that the application depends on such as: postgresql, memcached & elasticsearch. +postgresql, memcached & elasticsearch. + +Note: Docker compose does not run the rubygems.org application itself. -* Install Docker. See instructions at https://docs.docker.com/engine/installation/ -* run `docker-compose up` to start the required services. +* Install Docker. See instructions at https://docs.docker.com/get-docker/ +* run `docker compose up` to start the required services. Follow the instructions below on how to install Bundler and setup the database. #### Environment (OS X) -* Use Ruby 2.6.x (`.ruby-version` is present and can be used) -* Use Rubygems 3.1.5 -* Install bundler: `gem install bundler` -* Install Elastic Search: - * Pull ElasticSearch `5.6.16` : `docker pull docker.elastic.co/elasticsearch/elasticsearch:5.6.16` +* Install Elasticsearch: + + * Pull Elasticsearch `7.10.1` : `docker pull docker.elastic.co/elasticsearch/elasticsearch:7.10.1` * Running Elasticsearch from the command line: + ``` - docker run -p 9200:9200 -e "http.host=0.0.0.0" -e "transport.host=127.0.0.1" -e "xpack.security.enabled=false" docker.elastic.co/elasticsearch/elasticsearch:5.6.16 + docker run -p 9200:9200 -e "http.host=0.0.0.0" -e "transport.host=127.0.0.1" -e "xpack.security.enabled=false" docker.elastic.co/elasticsearch/elasticsearch:7.10.1 ``` + * Note that `-e "xpack.security.enabled=false"` disables authentication. -* Install PostgreSQL (>= 9.6.x): `brew install postgres` +* Install PostgreSQL (>= 13.x): `brew install postgres` * Setup information: `brew info postgresql` * Install memcached: `brew install memcached` * Show all memcached options: `memcached -h` -* Install Google-Chrome: `brew cask install google-chrome` +* Install Google-Chrome: `brew install google-chrome --cask` #### Environment (Linux - Debian/Ubuntu) -* Use Ruby 2.6.x `apt-get install ruby2.6` - * Or install via [alternate methods](https://www.ruby-lang.org/en/downloads/) -* Use Rubygems 3.1.5 -* Install bundler: `gem install bundler` -* Install Elastic Search (see the docker installation instructions above): - * Pull ElasticSearch `5.6.16` : `docker pull docker.elastic.co/elasticsearch/elasticsearch:5.6.16` +* Install Elasticsearch (see the docker installation instructions above): + * Pull Elasticsearch `7.10.1` : `docker pull docker.elastic.co/elasticsearch/elasticsearch:7.10.1` * Running Elasticsearch from the command line: ``` - docker run -p 9200:9200 -e "http.host=0.0.0.0" -e "transport.host=127.0.0.1" docker.elastic.co/elasticsearch/elasticsearch:5.6.16 + docker run -p 9200:9200 -e "http.host=0.0.0.0" -e "transport.host=127.0.0.1" docker.elastic.co/elasticsearch/elasticsearch:7.10.1 ``` * Install PostgreSQL: `apt-get install postgresql postgresql-server-dev-all` * Help to setup database @@ -98,29 +110,36 @@ Follow the instructions below on how to install Bundler and setup the database. * Show all memcached options: `memcached -h` * Install Google-Chrome: * Download latest stable: `wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb` - * Install chrome: sudo dpkg -i google-chrome-stable_current_amd64.deb - -#### Getting the code + * Install chrome: `sudo dpkg -i google-chrome-stable_current_amd64.deb` + +### Installing ruby, gem dependencies, and setting up the database + +* Use Ruby 3.3.x + * See: [Ruby install instructions](https://www.ruby-lang.org/en/downloads/). + * `.ruby-version` is present and can be used. +* Use Rubygems 3.5.x +* Install bundler: + `gem install bundler` +* Install dependencies and setup the database: + `./bin/setup` +* Set up elasticsearch indexes: + `bundle exec rake searchkick:reindex CLASS=Rubygem` -* Clone the repo: `git clone git://github.com/rubygems/rubygems.org` -* Move into your cloned rubygems directory if you haven’t already: - `cd rubygems.org` -* Install dependencies: - `bundle install` +### Running tests -#### Setting up the database +Make sure that the tests run successfully before making changes. -* Get set up: `./script/setup` -* Run the database rake tasks if needed: - `bundle exec rake db:reset db:test:prepare --trace` -* Set up elasticsearch indexes: - `bundle exec rake environment elasticsearch:import:all DIR=app/models FORCE=y` +* Depending on how you setup your environment, run `docker compose up` or + ensure elasticsearch, memcached, and postgres are running. +* Run the tests: `bin/rails test:all` +* See also: [Ruby on Rails testing documentation](https://guides.rubyonrails.org/testing.html). -#### Running tests +### Running the application -* Start elastic search: `elasticsearch` -* Start memcached: `memcached` -* Run the tests: `bundle exec rake` +* Depending on how you setup your environment, run `docker compose up` or + ensure elasticsearch, memcached, and postgres are running. +* Start the application: `bin/rails s` +* Visit http://localhost:3000 in your browser. #### Confirmation emails links @@ -138,12 +157,12 @@ build will fail. If you'd like RuboCop to attempt to automatically fix your style offenses, you can try running: - bundle exec rake rubocop:auto_correct + bundle exec rake rubocop:autocorrect #### Importing gems into the database * Import gems into the database with Rake task. - `bundle exec rake gemcutter:import:process vendor/cache` + `bundle exec rake "gemcutter:import:process[vendor/cache]"` * _To import a small set of gems you can point the import process to any gems cache directory, like a very small `rvm` gemset for instance, or specifying `GEM_PATH/cache` instead of `vendor/cache`._ @@ -154,18 +173,18 @@ can try running: * A good way to get some test data is to import from a local gem directory. `gem env` will tell you where rubygems stores your gems. Run -`bundle exec rake gemcutter:import:process #{INSTALLATION_DIRECTORY}/cache` +`bundle exec rake "gemcutter:import:process[#{INSTALLATION_DIRECTORY}/cache]"` * If you see "Processing 0 gems" you’ve probably specified the wrong directory. The proper directory will be full of .gem files. #### Getting the data dumps -* You can use rubygems.org data [dumps](https://rubygems.org/pages/data) to test -application in development environment especially for performance related issues. -* To load the main database dump into Postgres, use `script/load-pg-dump`. e.g. +* You can use rubygems.org data [dumps](https://rubygems.org/pages/data) to test the +application in a development environment, especially for performance-related issues. +* To load the main database dump into Postgres, use the `script/load-pg-dump` script. e.g. ``` bash - $ ./script/load-pg-dump -d rubygems_development + $ ./script/load-pg-dump -d rubygems_development ~/Downloads/public_postgresql.tar ``` #### Pushing gems @@ -176,14 +195,51 @@ application in development environment especially for performance related issues ``` bash RUBYGEMS_HOST=http://localhost:3000 gem push hola-0.0.3.gem ``` + +#### Developing with dev secrets + +If you're a member of the RubyGems.org team and have access to development secrets in the shared 1Password, +you can automatically use those secrets by installing the [1Password CLI](https://developer.1password.com/docs/cli) +and prefixing your commands with `script/dev`. + +For example, running `script/dev bin/rails s` will launch the development server with development secrets set in +the environment. + +#### Running with local RSTUF + +There is experimental [RSTUF](https://repository-service-tuf.readthedocs.io/en/stable/) support in RubyGems.org. When `RSTUF_API_URL` environment variable is set, RSTUF functionality is enabled. Easiest way to setup RSTUF locally is to follow [official docker guide](https://repository-service-tuf.readthedocs.io/en/latest/guide/deployment/guide/docker.html). It starts RSTUF API available at `http://localhost:80` by default and app can be locally started using following command. + +```bash +RSTUF_API_URL="http://localhost:80" bin/rails s +``` + --- When everything is set up, start the web server with `rails server` and browse to -[localhost:3000](http://localhost:3000) or use [Pow](http://pow.cx)! +[localhost:3000](http://localhost:3000)! + +#### Running with local Timescale + +There is early and experimental [TimescaleDB](https://docs.timescale.com/self-hosted/latest/) support in RubyGems.org for downloads statistics. When secondary `downloads` database is configured (using `database.yml` or by providing `DOWNLOADS_DATABASE_URL` environment variable), experimental features are automatically enabled. + +Localy, the easiest way is to run TimescaleDB using `docker-compose.yml` and configure using `database.yml.ts-sample`. + +```bash +cp config/database.yml.ts-sample config/database.yml +docker compose up -d db cache search # run required dependencies +docker compose up -d downloads-db # run optional TimescaleDB dependency +bin/rails db:setup # setup all databases, including optional Timescale one +bin/rails s # start rails server as ususal +``` Database Layout --------------- Courtesy of [Rails ERD](https://voormedia.github.io/rails-erd/) -![Rubygems.org Domain Model](https://cdn.rawgit.com/rubygems/rubygems.org/master/doc/erd.svg) + bin/rails gen_erd + +Locales +------- + +You can add the translations in `config/locales/en.yml` then use `bin/fill-locales` to fill the other locales with `nil` values for your translations. diff --git a/Dockerfile b/Dockerfile index 36236bdecef..6e1085d6e9d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,61 +1,130 @@ -FROM ruby:2.7-alpine as build +# syntax = docker/dockerfile:1.4 +# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile +ARG RUBY_VERSION=3.3.5 +ARG ALPINE_VERSION=3.20 +FROM ruby:$RUBY_VERSION-alpine${ALPINE_VERSION} as base + +# Install packages +RUN --mount=type=cache,id=dev-apk-cache,sharing=locked,target=/var/cache/apk \ + --mount=type=cache,id=dev-apk-lib,sharing=locked,target=/var/lib/apk \ + apk add \ + ca-certificates \ + bash \ + tzdata \ + xz-libs \ + gcompat \ + zstd-libs \ + libpq + +# Rails app lives here +RUN mkdir -p /app /app/config /app/log/ + +# Set production environment +ENV BUNDLE_APP_CONFIG=".bundle_app_config" + +# Update rubygems ARG RUBYGEMS_VERSION +RUN gem update --system ${RUBYGEMS_VERSION} --no-document + +# Throw-away build stage to reduce size of final image +FROM base as build -RUN apk add --no-cache \ +# Install packages +RUN \ + --mount=type=cache,id=dev-apk-cache,sharing=locked,target=/var/cache/apk \ + --mount=type=cache,id=dev-apk-lib,sharing=locked,target=/var/lib/apk \ + apk add \ nodejs \ postgresql-dev \ - ca-certificates \ build-base \ - bash \ linux-headers \ zlib-dev \ - tzdata \ - && rm -rf /var/cache/apk/* + tzdata -RUN mkdir -p /app /app/config /app/log/ WORKDIR /app -RUN gem update --system $RUBYGEMS_VERSION +ENV RAILS_ENV="production" -COPY . /app +# Install application gems +COPY Gemfile* .ruby-version /app/ +RUN --mount=type=cache,id=bld-gem-cache,sharing=locked,target=/srv/vendor < REVISION + +# Stop bootsnap from writing to the filesystem, we precompiled it in the build stage +ENV BOOTSNAP_READONLY=true EXPOSE 3000 +# Ensures ruby commands are run with bundler ENTRYPOINT ["bundle", "exec"] + +# Start the server by default, this can be overwritten at runtime CMD ["rails", "server", "-b", "0.0.0.0"] diff --git a/Gemfile b/Gemfile index 21d470723eb..8e5610a9a73 100644 --- a/Gemfile +++ b/Gemfile @@ -1,78 +1,148 @@ source "https://rubygems.org" -gem "rails", "~> 6.1.0" -gem "rails-i18n" +ruby file: ".ruby-version" -gem "aws-sdk", "~> 2.2" -gem "bootsnap" -gem "clearance" -gem "dalli" -gem "delayed_job" -gem "delayed_job_active_record" -gem "gravtastic" -gem "high_voltage" -gem "honeybadger" -gem "http_accept_language" -gem "jquery-rails" -gem "kaminari" -gem "mail" -gem "newrelic_rpm" -gem "pg" -gem "rack" -gem "rack-utf8_sanitizer" -gem "rbtrace", "~> 0.4.8" -gem "rdoc" -gem "rest-client", require: "rest_client" -gem "roadie-rails" -gem "shoryuken", "~> 2.1.0", require: false -gem "statsd-instrument", "~> 2.3.0" -gem "unicorn", "~> 5.5.0.1.g6836" -gem "validates_formatting_of" -gem "elasticsearch-model", "~> 5.0.0" -gem "elasticsearch-rails", "~> 5.0.0" -gem "elasticsearch-dsl", "~> 0.1.2" -gem "faraday_middleware-aws-sigv4", "~> 0.2.4" -gem "xml-simple" -gem "compact_index", "~> 0.13.0" -gem "sprockets-rails" -gem "rack-attack" -gem "rqrcode" -gem "rotp" -gem "unpwn", "~> 0.3.0" +gem "rails", "~> 7.2.1" +gem "rails-i18n", "~> 7.0" + +gem "aws-sdk-s3", "~> 1.167" +gem "aws-sdk-sqs", "~> 1.86" +gem "bootsnap", "~> 1.18" +gem "clearance", "~> 2.8" +gem "dalli", "~> 3.2" +gem "datadog", "~> 2.3" +gem "dogstatsd-ruby", "~> 5.6" +gem "google-protobuf", "~> 4.28" +gem "faraday", "~> 2.12" +gem "faraday-retry", "~> 2.2" +gem "faraday-restrict-ip-addresses", "~> 0.3.0", require: "faraday/restrict_ip_addresses" +gem "good_job", "~> 3.99" +gem "gravtastic", "~> 3.2" +gem "honeybadger", "~> 5.5.1", require: false # see https://github.com/rubygems/rubygems.org/pull/4598 +gem "http_accept_language", "~> 2.1" +gem "kaminari", "~> 1.2" +gem "launchdarkly-server-sdk", "~> 8.7" +gem "mail", "~> 2.8" +gem "octokit", "~> 9.1" +gem "omniauth-github", "~> 2.0" +gem "omniauth", "~> 2.1" +gem "omniauth-rails_csrf_protection", "~> 1.0" +gem "openid_connect", "~> 2.3" +gem "pg", "~> 1.5" +gem "puma", "~> 6.4" +gem "rack", "~> 3.1" +gem "rackup", "~> 2.1" +gem "rack-sanitizer", "~> 2.0" +gem "rbtrace", "~> 0.5.1" +gem "rdoc", "~> 6.7" +gem "roadie-rails", "~> 3.2" +gem "ruby-magic", "~> 0.6" +gem "shoryuken", "~> 6.2", require: false +gem "statsd-instrument", "~> 3.9" +gem "validates_formatting_of", "~> 0.9" +gem "opensearch-ruby", "~> 3.4" +gem "searchkick", "~> 5.4" +gem "faraday_middleware-aws-sigv4", "~> 1.0" +gem "xml-simple", "~> 1.1" +gem "compact_index", "~> 0.15.0" +gem "rack-attack", "~> 6.6" +gem "rqrcode", "~> 2.1" +gem "rotp", "~> 6.2" +gem "unpwn", "~> 1.0" +gem "webauthn", "~> 3.1" +gem "browser", "~> 6.0" +gem "bcrypt", "~> 3.1" +gem "maintenance_tasks", "~> 2.8" +gem "strong_migrations", "~> 2.0" +gem "phlex-rails", "~> 1.2" +gem "discard", "~> 1.3" +gem "user_agent_parser", "~> 2.18" +gem "pghero", "~> 3.6" +gem "faraday-multipart", "~> 1.0" +gem "timescaledb", "~> 0.3" + +# Admin dashboard +gem "avo", "~> 2.53" +gem "pagy", "~> 8.4" +gem "view_component", "~> 3.14" +gem "pundit", "~> 2.4" +gem "chartkick", "~> 5.1" +gem "groupdate", "~> 6.5" # Logging -gem "lograge" +gem "amazing_print", "~> 1.6" +gem "rails_semantic_logger", "~> 4.17" +gem "pp", "0.5.0" + +# Former default gems +gem "csv", "~> 3.3" # zeitwerk-2.6.12 +gem "observer", "~> 0.1.2" # launchdarkly-server-sdk-8.0.0 + +# Assets +gem "sprockets-rails", "~> 3.5" +gem "importmap-rails", "~> 2.0" +gem "stimulus-rails", "~> 1.3" # this adds stimulus-loading.js so it must be available at runtime +gem "local_time", "~> 3.0" +gem "better_html", "~> 2.1" + +group :assets, :development do + gem "tailwindcss-rails", "~> 2.7" +end group :assets do - gem "sassc-rails" - gem "uglifier", ">= 1.0.3" - gem "autoprefixer-rails" + gem "dartsass-sprockets", "~> 3.1" + gem "terser", "~> 1.2" + gem "autoprefixer-rails", "~> 10.4" end group :development, :test do - gem "m", "~> 1.5", require: false - gem "pry-byebug" - gem "rubocop", require: false - gem "rubocop-rails", require: false - gem "rubocop-performance", require: false - gem "brakeman", require: false - gem "toxiproxy", "~> 1.0.0" + gem "pry-byebug", "~> 3.10" + gem "toxiproxy", "~> 2.0" + gem "factory_bot_rails", "~> 6.4" + gem "dotenv-rails", "~> 3.1" + gem "lookbook", "~> 2.3" + + gem "brakeman", "~> 6.2", require: false + + # used to find n+1 queries + gem "prosopite", "~> 1.4" + gem "pg_query", "~> 5.1" + + # bundle show | rg rubocop | cut -d' ' -f4 | xargs bundle update + gem "rubocop", "~> 1.64", require: false + gem "rubocop-rails", "~> 2.25", require: false + gem "rubocop-performance", "~> 1.21", require: false + gem "rubocop-minitest", "~> 0.35", require: false + gem "rubocop-capybara", "~> 2.21", require: false + gem "rubocop-factory_bot", "~> 2.26", require: false end group :development do - gem "rails-erd" - gem "listen" + gem "rails-erd", "~> 1.7" + gem "listen", "~> 3.9" + gem "letter_opener", "~> 1.10" + gem "letter_opener_web", "~> 3.0" + gem "derailed_benchmarks", "~> 2.1" + gem "memory_profiler", "~> 1.1" end group :test do - gem "minitest", require: false - gem "capybara", "~> 2.18" - gem "factory_bot_rails" - gem "launchy" - gem "rack-test", require: "rack/test" - gem "mocha", require: false - gem "shoulda" - gem "selenium-webdriver" - gem "webdrivers" - gem "simplecov", require: false + gem "datadog-ci", "~> 1.7" + gem "minitest", "~> 5.25", require: false + gem "minitest-retry", "~> 0.2.3" + gem "capybara", "~> 3.40" + gem "launchy", "~> 3.0" + gem "rack-test", "~> 2.1", require: "rack/test" + gem "rails-controller-testing", "~> 1.0" + gem "mocha", "~> 2.4", require: false + gem "shoulda-context", "~> 3.0.0.rc1" + gem "shoulda-matchers", "~> 6.4" + gem "selenium-webdriver", "~> 4.25" + gem "webmock", "~> 3.24" + gem "simplecov", "~> 0.22", require: false + gem "simplecov-cobertura", "~> 2.1", require: false + gem "aggregate_assertions", "~> 0.2.0" + gem "minitest-gcstats", "~> 1.3" + gem "minitest-reporters", "~> 1.7" + gem "gem_server_conformance", "~> 0.1.4" end diff --git a/Gemfile.lock b/Gemfile.lock index 281d9814e1e..0472888c9a8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,121 +1,167 @@ GEM remote: https://rubygems.org/ specs: - actioncable (6.1.1) - actionpack (= 6.1.1) - activesupport (= 6.1.1) + actioncable (7.2.1) + actionpack (= 7.2.1) + activesupport (= 7.2.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.1.1) - actionpack (= 6.1.1) - activejob (= 6.1.1) - activerecord (= 6.1.1) - activestorage (= 6.1.1) - activesupport (= 6.1.1) - mail (>= 2.7.1) - actionmailer (6.1.1) - actionpack (= 6.1.1) - actionview (= 6.1.1) - activejob (= 6.1.1) - activesupport (= 6.1.1) - mail (~> 2.5, >= 2.5.4) - rails-dom-testing (~> 2.0) - actionpack (6.1.1) - actionview (= 6.1.1) - activesupport (= 6.1.1) - rack (~> 2.0, >= 2.0.9) + zeitwerk (~> 2.6) + actionmailbox (7.2.1) + actionpack (= 7.2.1) + activejob (= 7.2.1) + activerecord (= 7.2.1) + activestorage (= 7.2.1) + activesupport (= 7.2.1) + mail (>= 2.8.0) + actionmailer (7.2.1) + actionpack (= 7.2.1) + actionview (= 7.2.1) + activejob (= 7.2.1) + activesupport (= 7.2.1) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (7.2.1) + actionview (= 7.2.1) + activesupport (= 7.2.1) + nokogiri (>= 1.8.5) + racc + rack (>= 2.2.4, < 3.2) + rack-session (>= 1.0.1) rack-test (>= 0.6.3) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.1.1) - actionpack (= 6.1.1) - activerecord (= 6.1.1) - activestorage (= 6.1.1) - activesupport (= 6.1.1) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (7.2.1) + actionpack (= 7.2.1) + activerecord (= 7.2.1) + activestorage (= 7.2.1) + activesupport (= 7.2.1) + globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (6.1.1) - activesupport (= 6.1.1) + actionview (7.2.1) + activesupport (= 7.2.1) builder (~> 3.1) - erubi (~> 1.4) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.1, >= 1.2.0) - activejob (6.1.1) - activesupport (= 6.1.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + active_link_to (1.0.5) + actionpack + addressable + activejob (7.2.1) + activesupport (= 7.2.1) globalid (>= 0.3.6) - activemodel (6.1.1) - activesupport (= 6.1.1) - activerecord (6.1.1) - activemodel (= 6.1.1) - activesupport (= 6.1.1) - activestorage (6.1.1) - actionpack (= 6.1.1) - activejob (= 6.1.1) - activerecord (= 6.1.1) - activesupport (= 6.1.1) - marcel (~> 0.3.1) - mimemagic (~> 0.3.2) - activesupport (6.1.1) - concurrent-ruby (~> 1.0, >= 1.0.2) + activemodel (7.2.1) + activesupport (= 7.2.1) + activerecord (7.2.1) + activemodel (= 7.2.1) + activesupport (= 7.2.1) + timeout (>= 0.4.0) + activestorage (7.2.1) + actionpack (= 7.2.1) + activejob (= 7.2.1) + activerecord (= 7.2.1) + activesupport (= 7.2.1) + marcel (~> 1.0) + activesupport (7.2.1) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) + logger (>= 1.4.2) minitest (>= 5.1) - tzinfo (~> 2.0) - zeitwerk (~> 2.3) - addressable (2.7.0) - public_suffix (>= 2.0.2, < 5.0) - argon2 (2.0.2) - ffi (~> 1.9) - ffi-compiler (>= 0.1) - ast (2.4.1) - autoprefixer-rails (10.2.0.0) - execjs - aws-eventstream (1.0.3) - aws-sdk (2.11.473) - aws-sdk-resources (= 2.11.473) - aws-sdk-core (2.11.473) - aws-sigv4 (~> 1.0) - jmespath (~> 1.0) - aws-sdk-resources (2.11.473) - aws-sdk-core (= 2.11.473) - aws-sigv4 (1.1.1) - aws-eventstream (~> 1.0, >= 1.0.2) - bcrypt (3.1.13) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + aes_key_wrap (1.1.0) + aggregate_assertions (0.2.0) + minitest (~> 5.0) + amazing_print (1.6.0) + android_key_attestation (0.3.0) + ansi (1.5.0) + argon2 (2.3.0) + ffi (~> 1.15) + ffi-compiler (~> 1.0) + ast (2.4.2) + attr_required (1.0.2) + autoprefixer-rails (10.4.19.0) + execjs (~> 2) + avo (2.53.0) + actionview (>= 6.0) + active_link_to + activerecord (>= 6.0) + addressable + docile + dry-initializer + httparty + inline_svg + meta-tags + pagy + turbo-rails + turbo_power (~> 0.5.0) + view_component (>= 2.54.0) + zeitwerk (>= 2.6.2) + awrence (1.2.1) + aws-eventstream (1.3.0) + aws-partitions (1.983.0) + aws-sdk-core (3.209.1) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.651.0) + aws-sigv4 (~> 1.9) + jmespath (~> 1, >= 1.6.1) + aws-sdk-kms (1.94.0) + aws-sdk-core (~> 3, >= 3.207.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.167.0) + aws-sdk-core (~> 3, >= 3.207.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sdk-sqs (1.86.0) + aws-sdk-core (~> 3, >= 3.207.0) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.10.0) + aws-eventstream (~> 1, >= 1.0.2) + base64 (0.2.0) + bcrypt (3.1.20) + benchmark-ips (2.12.0) + better_html (2.1.1) + actionview (>= 6.0) + activesupport (>= 6.0) + ast (~> 2.0) + erubi (~> 1.4) + parser (>= 2.4) + smart_properties + bigdecimal (3.1.8) + bindata (2.5.0) bitarray (1.2.0) bloomer (1.0.0) bitarray msgpack - bootsnap (1.5.1) - msgpack (~> 1.0) - brakeman (5.0.0) - builder (3.2.4) - byebug (11.1.1) - capybara (2.18.0) + bootsnap (1.18.4) + msgpack (~> 1.2) + brakeman (6.2.1) + racc + browser (6.0.0) + builder (3.3.0) + byebug (11.1.3) + capybara (3.40.0) addressable + matrix mini_mime (>= 0.1.3) - nokogiri (>= 1.3.3) - rack (>= 1.0.0) - rack-test (>= 0.5.4) - xpath (>= 2.0, < 4.0) - celluloid (0.17.3) - celluloid-essentials - celluloid-extras - celluloid-fsm - celluloid-pool - celluloid-supervision - timers (>= 4.1.1) - celluloid-essentials (0.20.5) - timers (>= 4.1.1) - celluloid-extras (0.20.5) - timers (>= 4.1.1) - celluloid-fsm (0.20.5) - timers (>= 4.1.1) - celluloid-pool (0.20.5) - timers (>= 4.1.1) - celluloid-supervision (0.20.6) - timers (>= 4.1.1) - childprocess (3.0.0) + nokogiri (~> 1.11) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + regexp_parser (>= 1.5, < 3.0) + xpath (~> 3.2) + cbor (0.5.9.8) + chartkick (5.1.0) + childprocess (5.0.0) choice (0.2.0) - chunky_png (1.3.15) - clearance (2.2.0) + chunky_png (1.4.0) + clearance (2.8.0) actionmailer (>= 5.0) activemodel (>= 5.0) activerecord (>= 5.0) @@ -123,360 +169,1144 @@ GEM bcrypt (>= 3.1.1) email_validator (~> 2.0) railties (>= 5.0) - coderay (1.1.2) - compact_index (0.13.0) - concurrent-ruby (1.1.7) + coderay (1.1.3) + compact_index (0.15.0) + concurrent-ruby (1.3.4) + connection_pool (2.4.1) + cose (1.3.0) + cbor (~> 0.5.9) + openssl-signature_algorithm (~> 1.0) + crack (1.0.0) + bigdecimal + rexml crass (1.0.6) - css_parser (1.7.1) + css_parser (1.17.1) addressable - dalli (2.7.11) - delayed_job (4.1.9) - activesupport (>= 3.0, < 6.2) - delayed_job_active_record (4.1.5) - activerecord (>= 3.0, < 6.2) - delayed_job (>= 3.0, < 5) - docile (1.3.4) - domain_name (0.5.20190701) - unf (>= 0.0.5, < 1.0.0) - elasticsearch (5.0.5) - elasticsearch-api (= 5.0.5) - elasticsearch-transport (= 5.0.5) - elasticsearch-api (5.0.5) - multi_json - elasticsearch-dsl (0.1.9) - elasticsearch-model (5.0.2) - activesupport (> 3) - elasticsearch (~> 5) - hashie - elasticsearch-rails (5.0.2) - elasticsearch-transport (5.0.5) - faraday - multi_json - email_validator (2.0.1) + csv (3.3.0) + dalli (3.2.8) + dartsass-sprockets (3.1.0) + railties (>= 4.0.0) + sassc-embedded (~> 1.69) + sprockets (> 3.0) + sprockets-rails + tilt + datadog (2.3.0) + debase-ruby_core_source (= 3.3.1) + libdatadog (~> 11.0.0.1.0) + libddwaf (~> 1.14.0.0.0) + msgpack + datadog-ci (1.7.0) + datadog (~> 2.3) + msgpack + date (3.3.4) + dead_end (4.0.0) + debase-ruby_core_source (3.3.1) + derailed_benchmarks (2.1.2) + benchmark-ips (~> 2) + dead_end + get_process_mem (~> 0) + heapy (~> 0) + memory_profiler (>= 0, < 2) + mini_histogram (>= 0.3.0) + rack (>= 1) + rack-test + rake (> 10, < 14) + ruby-statistics (>= 2.1) + thor (>= 0.19, < 2) + diff-lcs (1.5.1) + discard (1.3.0) + activerecord (>= 4.2, < 8) + docile (1.4.1) + dogstatsd-ruby (5.6.2) + domain_name (0.6.20240107) + dotenv (3.1.4) + dotenv-rails (3.1.4) + dotenv (= 3.1.4) + railties (>= 6.1) + drb (2.2.1) + dry-initializer (3.1.1) + email_validator (2.2.4) activemodel - erubi (1.10.0) - execjs (2.7.0) - factory_bot (6.1.0) + erubi (1.13.0) + et-orbi (1.2.11) + tzinfo + execjs (2.9.1) + factory_bot (6.4.5) activesupport (>= 5.0.0) - factory_bot_rails (6.1.0) - factory_bot (~> 6.1.0) + factory_bot_rails (6.4.3) + factory_bot (~> 6.4) railties (>= 5.0.0) - faraday (0.14.0) - multipart-post (>= 1.2, < 3) - faraday_middleware-aws-sigv4 (0.2.5) + faraday (2.12.0) + faraday-net_http (>= 2.0, < 3.4) + json + logger + faraday-follow_redirects (0.3.0) + faraday (>= 1, < 3) + faraday-multipart (1.0.4) + multipart-post (~> 2) + faraday-net_http (3.3.0) + net-http + faraday-restrict-ip-addresses (0.3.0) + faraday (> 1.0, < 3.0) + faraday-retry (2.2.1) + faraday (~> 2.0) + faraday_middleware-aws-sigv4 (1.0.1) aws-sigv4 (~> 1.0) - faraday (>= 0.9, < 0.15) - ffi (1.14.2) - ffi-compiler (1.0.1) - ffi (>= 1.0.0) + faraday (>= 2.0, < 3) + ffi (1.17.0) + ffi (1.17.0-aarch64-linux-gnu) + ffi (1.17.0-arm64-darwin) + ffi (1.17.0-x86_64-darwin) + ffi (1.17.0-x86_64-linux-gnu) + ffi (1.17.0-x86_64-linux-musl) + ffi-compiler (1.3.2) + ffi (>= 1.15.5) rake - globalid (0.4.2) - activesupport (>= 4.2.0) + fugit (1.11.1) + et-orbi (~> 1, >= 1.2.11) + raabro (~> 1.4) + gem_server_conformance (0.1.4) + rspec (~> 3.0) + get_process_mem (0.2.7) + ffi (~> 1.0) + globalid (1.2.1) + activesupport (>= 6.1) + good_job (3.99.1) + activejob (>= 6.0.0) + activerecord (>= 6.0.0) + concurrent-ruby (>= 1.0.2) + fugit (>= 1.1) + railties (>= 6.0.0) + thor (>= 0.14.1) + google-protobuf (4.28.2) + bigdecimal + rake (>= 13) + google-protobuf (4.28.2-aarch64-linux) + bigdecimal + rake (>= 13) + google-protobuf (4.28.2-arm64-darwin) + bigdecimal + rake (>= 13) + google-protobuf (4.28.2-x86_64-darwin) + bigdecimal + rake (>= 13) + google-protobuf (4.28.2-x86_64-linux) + bigdecimal + rake (>= 13) gravtastic (3.2.6) - hashie (3.5.7) - high_voltage (3.1.2) - hitimes (1.2.5) - honeybadger (4.7.2) - http-accept (1.7.0) - http-cookie (1.0.3) + groupdate (6.5.1) + activesupport (>= 7) + hashdiff (1.1.1) + hashie (5.0.0) + heapy (0.2.0) + thor + honeybadger (5.5.1) + htmlbeautifier (1.4.3) + htmlentities (4.3.4) + http (5.2.0) + addressable (~> 2.8) + base64 (~> 0.1) + http-cookie (~> 1.0) + http-form_data (~> 2.2) + llhttp-ffi (~> 0.5.0) + http-cookie (1.0.7) domain_name (~> 0.5) + http-form_data (2.3.0) http_accept_language (2.1.1) - i18n (1.8.7) + httparty (0.22.0) + csv + mini_mime (>= 1.0.0) + multi_xml (>= 0.5.2) + i18n (1.14.6) concurrent-ruby (~> 1.0) - jmespath (1.4.0) - jquery-rails (4.4.0) - rails-dom-testing (>= 1, < 3) - railties (>= 4.2.0) - thor (>= 0.14, < 2.0) - kaminari (1.2.1) + importmap-rails (2.0.1) + actionpack (>= 6.0.0) + activesupport (>= 6.0.0) + railties (>= 6.0.0) + inline_svg (1.10.0) + activesupport (>= 3.0) + nokogiri (>= 1.6) + io-console (0.7.2) + irb (1.14.1) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + jmespath (1.6.2) + job-iteration (1.5.1) + activejob (>= 5.2) + json (2.7.2) + json-jwt (1.16.6) + activesupport (>= 4.2) + aes_key_wrap + base64 + bindata + faraday (~> 2.0) + faraday-follow_redirects + jwt (2.7.1) + kaminari (1.2.2) activesupport (>= 4.1.0) - kaminari-actionview (= 1.2.1) - kaminari-activerecord (= 1.2.1) - kaminari-core (= 1.2.1) - kaminari-actionview (1.2.1) + kaminari-actionview (= 1.2.2) + kaminari-activerecord (= 1.2.2) + kaminari-core (= 1.2.2) + kaminari-actionview (1.2.2) actionview - kaminari-core (= 1.2.1) - kaminari-activerecord (1.2.1) + kaminari-core (= 1.2.2) + kaminari-activerecord (1.2.2) activerecord - kaminari-core (= 1.2.1) - kaminari-core (1.2.1) - kgio (2.11.2) - launchy (2.5.0) - addressable (~> 2.7) - listen (3.4.1) + kaminari-core (= 1.2.2) + kaminari-core (1.2.2) + language_server-protocol (3.17.0.3) + launchdarkly-server-sdk (8.7.1) + concurrent-ruby (~> 1.1) + http (>= 4.4.0, < 6.0.0) + json (~> 2.3) + ld-eventsource (= 2.2.2) + observer (~> 0.1.2) + semantic (~> 1.6) + zlib (~> 3.1) + launchy (3.0.1) + addressable (~> 2.8) + childprocess (~> 5.0) + ld-eventsource (2.2.2) + concurrent-ruby (~> 1.0) + http (>= 4.4.1, < 6.0.0) + letter_opener (1.10.0) + launchy (>= 2.2, < 4) + letter_opener_web (3.0.0) + actionmailer (>= 6.1) + letter_opener (~> 1.9) + railties (>= 6.1) + rexml + libdatadog (11.0.0.1.0) + libdatadog (11.0.0.1.0-aarch64-linux) + libdatadog (11.0.0.1.0-x86_64-linux) + libddwaf (1.14.0.0.0) + ffi (~> 1.0) + libddwaf (1.14.0.0.0-aarch64-linux) + ffi (~> 1.0) + libddwaf (1.14.0.0.0-arm64-darwin) + ffi (~> 1.0) + libddwaf (1.14.0.0.0-x86_64-darwin) + ffi (~> 1.0) + libddwaf (1.14.0.0.0-x86_64-linux) + ffi (~> 1.0) + listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - lograge (0.11.2) - actionpack (>= 4) - activesupport (>= 4) - railties (>= 4) - request_store (~> 1.0) - loofah (2.8.0) + llhttp-ffi (0.5.0) + ffi-compiler (~> 1.0) + rake (~> 13.0) + local_time (3.0.2) + logger (1.6.1) + loofah (2.22.0) crass (~> 1.0.2) - nokogiri (>= 1.5.9) - m (1.5.1) - method_source (>= 0.6.7) - rake (>= 0.9.2.2) - mail (2.7.1) + nokogiri (>= 1.12.0) + lookbook (2.3.2) + activemodel + css_parser + htmlbeautifier (~> 1.3) + htmlentities (~> 4.3.4) + marcel (~> 1.0) + railties (>= 5.0) + redcarpet (~> 3.5) + rouge (>= 3.26, < 5.0) + view_component (>= 2.0) + yard (~> 0.9) + zeitwerk (~> 2.5) + mail (2.8.1) mini_mime (>= 0.1.1) - marcel (0.3.3) - mimemagic (~> 0.3.2) - method_source (1.0.0) - mime-types (3.3.1) - mime-types-data (~> 3.2015) - mime-types-data (3.2019.1009) - mimemagic (0.3.5) - mini_mime (1.0.2) - mini_portile2 (2.5.0) - minitest (5.14.3) - mocha (1.12.0) - msgpack (1.3.3) - multi_json (1.13.1) - multipart-post (2.1.1) - netrc (0.11.0) - newrelic_rpm (6.15.0) - nio4r (2.5.4) - nokogiri (1.11.1) - mini_portile2 (~> 2.5.0) + net-imap + net-pop + net-smtp + maintenance_tasks (2.8.0) + actionpack (>= 6.0) + activejob (>= 6.0) + activerecord (>= 6.0) + csv + job-iteration (>= 1.3.6) + railties (>= 6.0) + zeitwerk (>= 2.6.2) + marcel (1.0.4) + matrix (0.4.2) + memory_profiler (1.1.0) + meta-tags (2.22.0) + actionpack (>= 6.0.0, < 8.1) + method_source (1.1.0) + mini_histogram (0.3.1) + mini_mime (1.1.5) + mini_portile2 (2.8.7) + minitest (5.25.1) + minitest-gcstats (1.3.1) + minitest (~> 5.0) + minitest-reporters (1.7.1) + ansi + builder + minitest (>= 5.0) + ruby-progressbar + minitest-retry (0.2.3) + minitest (>= 5.0) + mocha (2.4.5) + ruby2_keywords (>= 0.0.5) + msgpack (1.7.2) + multi_json (1.15.0) + multi_xml (0.7.1) + bigdecimal (~> 3.1) + multipart-post (2.4.1) + net-http (0.4.1) + uri + net-imap (0.4.15) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-smtp (0.5.0) + net-protocol + nio4r (2.7.3) + nokogiri (1.16.7) + mini_portile2 (~> 2.8.2) + racc (~> 1.4) + nokogiri (1.16.7-aarch64-linux) + racc (~> 1.4) + nokogiri (1.16.7-arm64-darwin) + racc (~> 1.4) + nokogiri (1.16.7-x86_64-darwin) racc (~> 1.4) - optimist (3.0.1) - parallel (1.19.2) - parser (2.7.1.4) + nokogiri (1.16.7-x86_64-linux) + racc (~> 1.4) + oauth2 (2.0.9) + faraday (>= 0.17.3, < 3.0) + jwt (>= 1.0, < 3.0) + multi_xml (~> 0.5) + rack (>= 1.2, < 4) + snaky_hash (~> 2.0) + version_gem (~> 1.1) + observer (0.1.2) + octokit (9.1.0) + faraday (>= 1, < 3) + sawyer (~> 0.9) + omniauth (2.1.2) + hashie (>= 3.4.6) + rack (>= 2.2.3) + rack-protection + omniauth-github (2.0.1) + omniauth (~> 2.0) + omniauth-oauth2 (~> 1.8) + omniauth-oauth2 (1.8.0) + oauth2 (>= 1.4, < 3) + omniauth (~> 2.0) + omniauth-rails_csrf_protection (1.0.2) + actionpack (>= 4.2) + omniauth (~> 2.0) + openid_connect (2.3.0) + activemodel + attr_required (>= 1.0.0) + email_validator + faraday (~> 2.0) + faraday-follow_redirects + json-jwt (>= 1.16) + mail + rack-oauth2 (~> 2.2) + swd (~> 2.0) + tzinfo + validate_url + webfinger (~> 2.0) + opensearch-ruby (3.4.0) + faraday (>= 1.0, < 3) + multi_json (>= 1.0) + openssl (3.2.0) + openssl-signature_algorithm (1.3.0) + openssl (> 2.0) + optimist (3.1.0) + pagy (8.6.3) + parallel (1.26.3) + parser (3.3.5.0) ast (~> 2.4.1) - pg (1.2.3) - pry (0.13.0) + racc + pg (1.5.8) + pg_query (5.1.0) + google-protobuf (>= 3.22.3) + pghero (3.6.0) + activerecord (>= 6.1) + phlex (1.10.2) + phlex-rails (1.2.1) + phlex (~> 1.10.0) + railties (>= 6.1, < 8) + pp (0.5.0) + prettyprint + prettyprint (0.2.0) + prosopite (1.4.2) + pry (0.14.1) coderay (~> 1.1) method_source (~> 1.0) - pry-byebug (3.9.0) + pry-byebug (3.10.1) byebug (~> 11.0) - pry (~> 0.13.0) - public_suffix (4.0.6) - pwned (1.2.1) - racc (1.5.2) - rack (2.2.3) - rack-attack (6.4.0) - rack (>= 1.0, < 3) - rack-test (1.1.0) - rack (>= 1.0, < 3) - rack-utf8_sanitizer (1.7.0) - rack (>= 1.0, < 3.0) - rails (6.1.1) - actioncable (= 6.1.1) - actionmailbox (= 6.1.1) - actionmailer (= 6.1.1) - actionpack (= 6.1.1) - actiontext (= 6.1.1) - actionview (= 6.1.1) - activejob (= 6.1.1) - activemodel (= 6.1.1) - activerecord (= 6.1.1) - activestorage (= 6.1.1) - activesupport (= 6.1.1) + pry (>= 0.13, < 0.15) + psych (5.1.2) + stringio + public_suffix (6.0.1) + puma (6.4.3) + nio4r (~> 2.0) + pundit (2.4.0) + activesupport (>= 3.0.0) + pwned (2.3.0) + raabro (1.4.0) + racc (1.8.1) + rack (3.1.7) + rack-attack (6.7.0) + rack (>= 1.0, < 4) + rack-oauth2 (2.2.1) + activesupport + attr_required + faraday (~> 2.0) + faraday-follow_redirects + json-jwt (>= 1.11.0) + rack (>= 2.1.0) + rack-protection (4.0.0) + base64 (>= 0.1.0) + rack (>= 3.0.0, < 4) + rack-sanitizer (2.0.3) + rack (>= 1.0, < 4.0) + rack-session (2.0.0) + rack (>= 3.0.0) + rack-test (2.1.0) + rack (>= 1.3) + rackup (2.1.0) + rack (>= 3) + webrick (~> 1.8) + rails (7.2.1) + actioncable (= 7.2.1) + actionmailbox (= 7.2.1) + actionmailer (= 7.2.1) + actionpack (= 7.2.1) + actiontext (= 7.2.1) + actionview (= 7.2.1) + activejob (= 7.2.1) + activemodel (= 7.2.1) + activerecord (= 7.2.1) + activestorage (= 7.2.1) + activesupport (= 7.2.1) bundler (>= 1.15.0) - railties (= 6.1.1) - sprockets-rails (>= 2.0.0) - rails-dom-testing (2.0.3) - activesupport (>= 4.2.0) + railties (= 7.2.1) + rails-controller-testing (1.0.5) + actionpack (>= 5.0.1.rc1) + actionview (>= 5.0.1.rc1) + activesupport (>= 5.0.1.rc1) + rails-dom-testing (2.2.0) + activesupport (>= 5.0.0) + minitest nokogiri (>= 1.6) - rails-erd (1.6.0) + rails-erd (1.7.2) activerecord (>= 4.2) activesupport (>= 4.2) choice (~> 0.2.0) ruby-graphviz (~> 1.2) - rails-html-sanitizer (1.3.0) - loofah (~> 2.3) - rails-i18n (6.0.0) + rails-html-sanitizer (1.6.0) + loofah (~> 2.21) + nokogiri (~> 1.14) + rails-i18n (7.0.9) i18n (>= 0.7, < 2) - railties (>= 6.0.0, < 7) - railties (6.1.1) - actionpack (= 6.1.1) - activesupport (= 6.1.1) - method_source - rake (>= 0.8.7) - thor (~> 1.0) - rainbow (3.0.0) - raindrops (0.19.0) - rake (13.0.3) - rb-fsevent (0.10.4) + railties (>= 6.0.0, < 8) + rails_semantic_logger (4.17.0) + rack + railties (>= 5.1) + semantic_logger (~> 4.16) + railties (7.2.1) + actionpack (= 7.2.1) + activesupport (= 7.2.1) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + zeitwerk (~> 2.6) + rainbow (3.1.1) + rake (13.2.1) + rb-fsevent (0.11.2) rb-inotify (0.10.1) ffi (~> 1.0) - rbtrace (0.4.14) + rbtrace (0.5.1) ffi (>= 1.0.6) msgpack (>= 0.4.3) optimist (>= 3.0.0) - rdoc (6.3.0) - regexp_parser (1.7.1) - request_store (1.5.0) - rack (>= 1.4) - rest-client (2.1.0) - http-accept (>= 1.7.0, < 2.0) - http-cookie (>= 1.0.2, < 2.0) - mime-types (>= 1.16, < 4.0) - netrc (~> 0.8) - rexml (3.2.4) - roadie (4.0.0) + rdoc (6.7.0) + psych (>= 4.0.0) + redcarpet (3.6.0) + regexp_parser (2.9.2) + reline (0.5.10) + io-console (~> 0.5) + rexml (3.3.8) + roadie (5.2.1) css_parser (~> 1.4) - nokogiri (~> 1.8) - roadie-rails (2.2.0) - railties (>= 5.1, < 6.2) - roadie (>= 3.1, < 5.0) - rotp (6.2.0) - rqrcode (1.2.0) + nokogiri (~> 1.15) + roadie-rails (3.2.0) + railties (>= 5.1, < 8.0) + roadie (~> 5.0) + rotp (6.3.0) + rouge (4.3.0) + rqrcode (2.2.0) chunky_png (~> 1.0) - rqrcode_core (~> 0.2) - rqrcode_core (0.2.0) - rubocop (0.89.1) + rqrcode_core (~> 1.0) + rqrcode_core (1.2.0) + rspec (3.13.0) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.0) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.1) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.1) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.1) + rubocop (1.66.1) + json (~> 2.3) + language_server-protocol (>= 3.17.0) parallel (~> 1.10) - parser (>= 2.7.1.1) + parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.7) - rexml - rubocop-ast (>= 0.3.0, < 1.0) + regexp_parser (>= 2.4, < 3.0) + rubocop-ast (>= 1.32.2, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 1.4.0, < 2.0) - rubocop-ast (0.3.0) - parser (>= 2.7.1.4) - rubocop-performance (1.7.1) - rubocop (>= 0.82.0) - rubocop-rails (2.5.2) - activesupport - rack (>= 1.1) - rubocop (>= 0.72.0) - ruby-graphviz (1.2.4) - ruby-progressbar (1.10.1) - rubyzip (2.3.0) - sassc (2.4.0) - ffi (~> 1.9) - sassc-rails (2.1.2) - railties (>= 4.0.0) - sassc (>= 2.0) - sprockets (> 3.0) - sprockets-rails - tilt - selenium-webdriver (3.142.7) - childprocess (>= 0.5, < 4.0) - rubyzip (>= 1.2.2) - shoryuken (2.1.3) - aws-sdk-core (~> 2) - celluloid (~> 0.17) - shoulda (4.0.0) - shoulda-context (~> 2.0) - shoulda-matchers (~> 4.0) - shoulda-context (2.0.0) - shoulda-matchers (4.4.1) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.32.3) + parser (>= 3.3.1.0) + rubocop-capybara (2.21.0) + rubocop (~> 1.41) + rubocop-factory_bot (2.26.1) + rubocop (~> 1.61) + rubocop-minitest (0.36.0) + rubocop (>= 1.61, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) + rubocop-performance (1.22.1) + rubocop (>= 1.48.1, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) + rubocop-rails (2.26.2) activesupport (>= 4.2.0) - simplecov (0.21.2) + rack (>= 1.1) + rubocop (>= 1.52.0, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) + ruby-graphviz (1.2.5) + rexml + ruby-magic (0.6.0) + mini_portile2 (~> 2.8) + ruby-progressbar (1.13.0) + ruby-statistics (3.0.2) + ruby2_keywords (0.0.5) + rubyzip (2.3.2) + safety_net_attestation (0.4.0) + jwt (~> 2.0) + sass-embedded (1.72.0) + google-protobuf (>= 3.25, < 5.0) + rake (>= 13.0.0) + sass-embedded (1.72.0-aarch64-linux-gnu) + google-protobuf (>= 3.25, < 5.0) + sass-embedded (1.72.0-arm64-darwin) + google-protobuf (>= 3.25, < 5.0) + sass-embedded (1.72.0-x86_64-darwin) + google-protobuf (>= 3.25, < 5.0) + sass-embedded (1.72.0-x86_64-linux-gnu) + google-protobuf (>= 3.25, < 5.0) + sass-embedded (1.72.0-x86_64-linux-musl) + google-protobuf (>= 3.25, < 5.0) + sassc-embedded (1.70.1) + sass-embedded (~> 1.70) + sawyer (0.9.2) + addressable (>= 2.3.5) + faraday (>= 0.17.3, < 3) + searchkick (5.4.0) + activemodel (>= 6.1) + hashie + securerandom (0.3.1) + selenium-webdriver (4.25.0) + base64 (~> 0.2) + logger (~> 1.4) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 3.0) + websocket (~> 1.0) + semantic (1.6.1) + semantic_logger (4.16.0) + concurrent-ruby (~> 1.0) + shoryuken (6.2.1) + aws-sdk-core (>= 2) + concurrent-ruby + thor + shoulda-context (3.0.0.rc1) + shoulda-matchers (6.4.0) + activesupport (>= 5.2.0) + simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) + simplecov-cobertura (2.1.0) + rexml + simplecov (~> 0.19) simplecov-html (0.12.3) - simplecov_json_formatter (0.1.2) - sprockets (4.0.2) + simplecov_json_formatter (0.1.4) + smart_properties (1.17.0) + snaky_hash (2.0.1) + hashie + version_gem (~> 1.1, >= 1.1.1) + sprockets (4.2.1) concurrent-ruby (~> 1.0) - rack (> 1, < 3) - sprockets-rails (3.2.2) - actionpack (>= 4.0) - activesupport (>= 4.0) + rack (>= 2.2.4, < 4) + sprockets-rails (3.5.2) + actionpack (>= 6.1) + activesupport (>= 6.1) sprockets (>= 3.0.0) - statsd-instrument (2.3.5) - thor (1.0.1) - tilt (2.0.10) - timers (4.1.2) - hitimes - toxiproxy (1.0.3) - tzinfo (2.0.4) - concurrent-ruby (~> 1.0) - uglifier (4.2.0) + statsd-instrument (3.9.2) + stimulus-rails (1.3.4) + railties (>= 6.0.0) + stringio (3.1.1) + strong_migrations (2.0.0) + activerecord (>= 6.1) + swd (2.0.3) + activesupport (>= 3) + attr_required (>= 0.0.5) + faraday (~> 2.0) + faraday-follow_redirects + tailwindcss-rails (2.7.9) + railties (>= 7.0.0) + tailwindcss-rails (2.7.9-aarch64-linux) + railties (>= 7.0.0) + tailwindcss-rails (2.7.9-arm64-darwin) + railties (>= 7.0.0) + tailwindcss-rails (2.7.9-x86_64-darwin) + railties (>= 7.0.0) + tailwindcss-rails (2.7.9-x86_64-linux) + railties (>= 7.0.0) + terser (1.2.4) execjs (>= 0.3.0, < 3) - unf (0.1.4) - unf_ext - unf_ext (0.0.7.6) - unicode-display_width (1.7.0) - unicorn (5.5.0.1.g6836) - kgio (~> 2.6) - raindrops (~> 0.7) - unpwn (0.3.0) + thor (1.3.2) + tilt (2.3.0) + timeout (0.4.1) + timescaledb (0.3.0) + activerecord + activesupport + pg (~> 1.2) + toxiproxy (2.0.2) + tpm-key_attestation (0.12.0) + bindata (~> 2.4) + openssl (> 2.0) + openssl-signature_algorithm (~> 1.0) + turbo-rails (1.5.0) + actionpack (>= 6.0.0) + activejob (>= 6.0.0) + railties (>= 6.0.0) + turbo_power (0.5.0) + turbo-rails (~> 1.3) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (2.6.0) + unpwn (1.0.0) bloomer (~> 1.0) - pwned (~> 1.2) + pwned (~> 2.0) + uri (0.13.1) + user_agent_parser (2.18.0) + useragent (0.16.10) + validate_url (1.0.15) + activemodel (>= 3.0.0) + public_suffix validates_formatting_of (0.9.0) activemodel - webdrivers (4.5.0) - nokogiri (~> 1.6) - rubyzip (>= 1.3.0) - selenium-webdriver (>= 3.0, < 4.0) - websocket-driver (0.7.3) + version_gem (1.1.1) + view_component (3.14.0) + activesupport (>= 5.2.0, < 8.0) + concurrent-ruby (~> 1.0) + method_source (~> 1.0) + webauthn (3.1.0) + android_key_attestation (~> 0.3.0) + awrence (~> 1.1) + bindata (~> 2.4) + cbor (~> 0.5.9) + cose (~> 1.1) + openssl (>= 2.2) + safety_net_attestation (~> 0.4.0) + tpm-key_attestation (~> 0.12.0) + webfinger (2.1.3) + activesupport + faraday (~> 2.0) + faraday-follow_redirects + webmock (3.24.0) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) + webrick (1.8.2) + websocket (1.2.11) + websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) - xml-simple (1.1.8) - xpath (3.1.0) + xml-simple (1.1.9) + rexml + xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.4.2) + yard (0.9.36) + zeitwerk (2.7.0) + zlib (3.1.1) PLATFORMS + aarch64-linux + arm64-darwin + arm64-linux ruby + x86_64-darwin + x86_64-linux + x86_64-linux-musl DEPENDENCIES - autoprefixer-rails - aws-sdk (~> 2.2) - bootsnap - brakeman - capybara (~> 2.18) - clearance - compact_index (~> 0.13.0) - dalli - delayed_job - delayed_job_active_record - elasticsearch-dsl (~> 0.1.2) - elasticsearch-model (~> 5.0.0) - elasticsearch-rails (~> 5.0.0) - factory_bot_rails - faraday_middleware-aws-sigv4 (~> 0.2.4) - gravtastic - high_voltage - honeybadger - http_accept_language - jquery-rails - kaminari - launchy - listen - lograge - m (~> 1.5) - mail - minitest - mocha - newrelic_rpm - pg - pry-byebug - rack - rack-attack - rack-test - rack-utf8_sanitizer - rails (~> 6.1.0) - rails-erd - rails-i18n - rbtrace (~> 0.4.8) - rdoc - rest-client - roadie-rails - rotp - rqrcode - rubocop - rubocop-performance - rubocop-rails - sassc-rails - selenium-webdriver - shoryuken (~> 2.1.0) - shoulda - simplecov - sprockets-rails - statsd-instrument (~> 2.3.0) - toxiproxy (~> 1.0.0) - uglifier (>= 1.0.3) - unicorn (~> 5.5.0.1.g6836) - unpwn (~> 0.3.0) - validates_formatting_of - webdrivers - xml-simple + aggregate_assertions (~> 0.2.0) + amazing_print (~> 1.6) + autoprefixer-rails (~> 10.4) + avo (~> 2.53) + aws-sdk-s3 (~> 1.167) + aws-sdk-sqs (~> 1.86) + bcrypt (~> 3.1) + better_html (~> 2.1) + bootsnap (~> 1.18) + brakeman (~> 6.2) + browser (~> 6.0) + capybara (~> 3.40) + chartkick (~> 5.1) + clearance (~> 2.8) + compact_index (~> 0.15.0) + csv (~> 3.3) + dalli (~> 3.2) + dartsass-sprockets (~> 3.1) + datadog (~> 2.3) + datadog-ci (~> 1.7) + derailed_benchmarks (~> 2.1) + discard (~> 1.3) + dogstatsd-ruby (~> 5.6) + dotenv-rails (~> 3.1) + factory_bot_rails (~> 6.4) + faraday (~> 2.12) + faraday-multipart (~> 1.0) + faraday-restrict-ip-addresses (~> 0.3.0) + faraday-retry (~> 2.2) + faraday_middleware-aws-sigv4 (~> 1.0) + gem_server_conformance (~> 0.1.4) + good_job (~> 3.99) + google-protobuf (~> 4.28) + gravtastic (~> 3.2) + groupdate (~> 6.5) + honeybadger (~> 5.5.1) + http_accept_language (~> 2.1) + importmap-rails (~> 2.0) + kaminari (~> 1.2) + launchdarkly-server-sdk (~> 8.7) + launchy (~> 3.0) + letter_opener (~> 1.10) + letter_opener_web (~> 3.0) + listen (~> 3.9) + local_time (~> 3.0) + lookbook (~> 2.3) + mail (~> 2.8) + maintenance_tasks (~> 2.8) + memory_profiler (~> 1.1) + minitest (~> 5.25) + minitest-gcstats (~> 1.3) + minitest-reporters (~> 1.7) + minitest-retry (~> 0.2.3) + mocha (~> 2.4) + observer (~> 0.1.2) + octokit (~> 9.1) + omniauth (~> 2.1) + omniauth-github (~> 2.0) + omniauth-rails_csrf_protection (~> 1.0) + openid_connect (~> 2.3) + opensearch-ruby (~> 3.4) + pagy (~> 8.4) + pg (~> 1.5) + pg_query (~> 5.1) + pghero (~> 3.6) + phlex-rails (~> 1.2) + pp (= 0.5.0) + prosopite (~> 1.4) + pry-byebug (~> 3.10) + puma (~> 6.4) + pundit (~> 2.4) + rack (~> 3.1) + rack-attack (~> 6.6) + rack-sanitizer (~> 2.0) + rack-test (~> 2.1) + rackup (~> 2.1) + rails (~> 7.2.1) + rails-controller-testing (~> 1.0) + rails-erd (~> 1.7) + rails-i18n (~> 7.0) + rails_semantic_logger (~> 4.17) + rbtrace (~> 0.5.1) + rdoc (~> 6.7) + roadie-rails (~> 3.2) + rotp (~> 6.2) + rqrcode (~> 2.1) + rubocop (~> 1.64) + rubocop-capybara (~> 2.21) + rubocop-factory_bot (~> 2.26) + rubocop-minitest (~> 0.35) + rubocop-performance (~> 1.21) + rubocop-rails (~> 2.25) + ruby-magic (~> 0.6) + searchkick (~> 5.4) + selenium-webdriver (~> 4.25) + shoryuken (~> 6.2) + shoulda-context (~> 3.0.0.rc1) + shoulda-matchers (~> 6.4) + simplecov (~> 0.22) + simplecov-cobertura (~> 2.1) + sprockets-rails (~> 3.5) + statsd-instrument (~> 3.9) + stimulus-rails (~> 1.3) + strong_migrations (~> 2.0) + tailwindcss-rails (~> 2.7) + terser (~> 1.2) + timescaledb (~> 0.3) + toxiproxy (~> 2.0) + unpwn (~> 1.0) + user_agent_parser (~> 2.18) + validates_formatting_of (~> 0.9) + view_component (~> 3.14) + webauthn (~> 3.1) + webmock (~> 3.24) + xml-simple (~> 1.1) + +CHECKSUMS + actioncable (7.2.1) sha256=b409c96b0acc90abe6aa8fd9656eaff0980c1b36c9e22b8f7c490a46eafc2204 + actionmailbox (7.2.1) sha256=09c20d0bcb769a6521d22cb8987e2d1d8335b58610957a6c615c85e6743adf89 + actionmailer (7.2.1) sha256=e4853a32c84105066e64d900ee1025ef075893ee3c51de3a3bc59a6e09586e56 + actionpack (7.2.1) sha256=260b80acc720123f23eb2b106b04d2de7d8cf0492d4eeb2dfa7afc8be36dcaad + actiontext (7.2.1) sha256=1257a2384373188039fc35d46946e757014710361a4af4481e37b510ac7d7d79 + actionview (7.2.1) sha256=d1f8f4df2bff842a03e2a6e86275e4d73e70c654159617ad4abbe7c6b2aed4f4 + active_link_to (1.0.5) sha256=4830847b3d14589df1e9fc62038ceec015257fce975ec1c2a77836c461b139ba + activejob (7.2.1) sha256=eb145f5aaf8276f37b9e4e9f72f3d56b1733172b4be680e836c765f2e6a3c503 + activemodel (7.2.1) sha256=7b24e3927122b99c4623f07607a1d0f1cfd598f9dc5077e70178536dd6663348 + activerecord (7.2.1) sha256=b58a26b9337594f2639cafcc443f4d28d786289f5b5b07b810e8251eeace533c + activestorage (7.2.1) sha256=e5d6746aa9e5d92fff9d214fad782b6a7189bc080d319c0b196e3dfa1595a676 + activesupport (7.2.1) sha256=7557fa077a592a4f36f7ddacf4d9d71c34aff69ed20236b8a61c22d567da8c24 + addressable (2.8.7) sha256=462986537cf3735ab5f3c0f557f14155d778f4b43ea4f485a9deb9c8f7c58232 + aes_key_wrap (1.1.0) sha256=b935f4756b37375895db45669e79dfcdc0f7901e12d4e08974d5540c8e0776a5 + aggregate_assertions (0.2.0) sha256=9bc51a48323a8e7b82f47cc38d48132817247345e5a8713686c9d65b25daca9e + amazing_print (1.6.0) sha256=9903e93daadc15411c854239f8ca1ca6ce78be279ed99d43fec2bd3b0aa37397 + android_key_attestation (0.3.0) sha256=467eb01a99d2bb48ef9cf24cc13712669d7056cba5a52d009554ff037560570b + ansi (1.5.0) sha256=5408253274e33d9d27d4a98c46d2998266fd51cba58a7eb9d08f50e57ed23592 + argon2 (2.3.0) sha256=980ef65172bf512ad37b6cbb0d61eef40b6dccab6a7db4e70557527e1dce9557 + ast (2.4.2) sha256=1e280232e6a33754cde542bc5ef85520b74db2aac73ec14acef453784447cc12 + attr_required (1.0.2) sha256=f0ebfc56b35e874f4d0ae799066dbc1f81efefe2364ca3803dc9ea6a4de6cb99 + autoprefixer-rails (10.4.19.0) sha256=ccd21f47e5a07a581c7d239025e0d8d06a005825e96e06f72382d818fe665f4e + avo (2.53.0) sha256=e8f1fb9abaf6d874d898449d4db863586c16e60634a136d97ae436ded976d1a6 + awrence (1.2.1) sha256=dd1d214c12a91f449d1ef81d7ee3babc2816944e450752e7522c65521872483e + aws-eventstream (1.3.0) sha256=f1434cc03ab2248756eb02cfa45e900e59a061d7fbdc4a9fd82a5dd23d796d3f + aws-partitions (1.983.0) sha256=eb3ab086749ca426e735a50bd1ea669a4b7bc52be3c119a772bd277cc906bb11 + aws-sdk-core (3.209.1) sha256=18cfb211d37d70a3131743ba02a785668beefacebed9827829af5922deb9b91a + aws-sdk-kms (1.94.0) sha256=b0c623199f1f46bd82c1d1fa0feef105ab8cfc6bd2f643d71cd1c289e11a9da9 + aws-sdk-s3 (1.167.0) sha256=a6aab762b07f4d6c7c4e9e98ea8ec5b12b49be3849f29483ecd9a821702096ce + aws-sdk-sqs (1.86.0) sha256=defa5bd65679c8877c335cb5f46462620fa10c42115cdcc58333f1ddee9a0715 + aws-sigv4 (1.10.0) sha256=159b113dc93f9fa5a13603a8bd09eab54a9a0e8f98e606b47f1cee504780df9c + base64 (0.2.0) sha256=0f25e9b21a02a0cc0cea8ef92b2041035d39350946e8789c562b2d1a3da01507 + bcrypt (3.1.20) sha256=8410f8c7b3ed54a3c00cd2456bf13917d695117f033218e2483b2e40b0784099 + benchmark-ips (2.12.0) sha256=09dd4d5be05db42470e7e7b01be7310564073a054e35d9d9ec7840c523f3dbcb + better_html (2.1.1) sha256=046c3551d1488a3f2939a7cac6fabf2bde08c32e135c91fcd683380118e5af55 + bigdecimal (3.1.8) sha256=a89467ed5a44f8ae01824af49cbc575871fa078332e8f77ea425725c1ffe27be + bindata (2.5.0) sha256=29dccb8ba1cc9de148f24bb88930840c62db56715f0f80eccadd624d9f3d2623 + bitarray (1.2.0) sha256=7f9f31fadbd87bf51544cf13058e81cd6ec408ff40f127902cef3d6767b23f11 + bloomer (1.0.0) sha256=57a0d3a78628db9a92c6723f06c67697e420abcdb05aa757c6dfae607251d272 + bootsnap (1.18.4) sha256=ac4c42af397f7ee15521820198daeff545e4c360d2772c601fbdc2c07d92af55 + brakeman (6.2.1) sha256=862e709caa1abf00dd0c47045682404c349f64876c7be74a8e6a4d6be5f61a1d + browser (6.0.0) sha256=0399f0f12c925e529aa995b096a3824384e00ea2c7241fbb4b707d2a25e87920 + builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f + byebug (11.1.3) sha256=2485944d2bb21283c593d562f9ae1019bf80002143cc3a255aaffd4e9cf4a35b + capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef + cbor (0.5.9.8) sha256=9ee097fc58d9bc5e406d112cd2d4e112c7354ec16f8b6ff34e4732c1e44b4eb7 + chartkick (5.1.0) sha256=8f04a522ca5c55dea298fbd10b585c62aa3f3a206944bf6f96d6e1f17be60760 + childprocess (5.0.0) sha256=0746b7ab1d6c68156e64a3767631d7124121516192c0492929a7f0af7310d835 + choice (0.2.0) sha256=a19617f7dfd4921b38a85d0616446620de685a113ec6d1ecc85bdb67bf38c974 + chunky_png (1.4.0) sha256=89d5b31b55c0cf4da3cf89a2b4ebc3178d8abe8cbaf116a1dba95668502fdcfe + clearance (2.8.0) sha256=ec89e99f737a1b115a30624d27daa0501830a991acd93aa0eef7f2414540df52 + coderay (1.1.3) sha256=dc530018a4684512f8f38143cd2a096c9f02a1fc2459edcfe534787a7fc77d4b + compact_index (0.15.0) sha256=5c6c404afca8928a7d9f4dde9524f6e1610db17e675330803055db282da84a8b + concurrent-ruby (1.3.4) sha256=d4aa926339b0a86b5b5054a0a8c580163e6f5dcbdfd0f4bb916b1a2570731c32 + connection_pool (2.4.1) sha256=0f40cf997091f1f04ff66da67eabd61a9fe0d4928b9a3645228532512fab62f4 + cose (1.3.0) sha256=63247c66a5bc76e53926756574fe3724cc0a88707e358c90532ae2a320e98601 + crack (1.0.0) sha256=c83aefdb428cdc7b66c7f287e488c796f055c0839e6e545fec2c7047743c4a49 + crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d + css_parser (1.17.1) sha256=eb730f2d26591a843e52bd3d0efd76abdfeec8bad728e0b2ac821fc10bb018e6 + csv (3.3.0) sha256=0bbd1defdc31134abefed027a639b3723c2753862150f4c3ee61cab71b20d67d + dalli (3.2.8) sha256=2e63595084d91fae2655514a02c5d4fc0f16c0799893794abe23bf628bebaaa5 + dartsass-sprockets (3.1.0) sha256=c238ec9f7f496489ac5a7813cd1f83d1e077a1826921acefc7e290a521b7a20a + datadog (2.3.0) sha256=28e28a48dafa4c553ba32b9c4ffe0a9aa40a9795f024279e505b255b84908566 + datadog-ci (1.7.0) sha256=41c064e2c26cfc63fb6767522e5a99fa43ec017be4b2fddfce1ff9073bb6c385 + date (3.3.4) sha256=971f2cb66b945bcbea4ddd9c7908c9400b31a71bc316833cb42fa584b59d3291 + dead_end (4.0.0) sha256=695c8438993bb4c5415b1618a1b6e0afcae849ef2812fb8cb3846723904307eb + debase-ruby_core_source (3.3.1) sha256=ed904cae290edf0cf274ad707f8981bf1cefad8081e78d4bb71be2a483bc2c08 + derailed_benchmarks (2.1.2) sha256=eaadc6206ceeb5538ff8f5e04a0023d54ebdd95d04f33e8960fb95a5f189a14f + diff-lcs (1.5.1) sha256=273223dfb40685548436d32b4733aa67351769c7dea621da7d9dd4813e63ddfe + discard (1.3.0) sha256=55218997ca4dc11f0594f1bb2d1196c4a959ceb562f1ab6490130233598dda67 + docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e + dogstatsd-ruby (5.6.2) sha256=3d952a2415e382fd91059f1c9c6a1fc5dde354fa39266ae47a38c2b518424db1 + domain_name (0.6.20240107) sha256=5f693b2215708476517479bf2b3802e49068ad82167bcd2286f899536a17d933 + dotenv (3.1.4) sha256=6dc502e718ea0d3542673345da05c7a69039840898e251757adb3405d2b35629 + dotenv-rails (3.1.4) sha256=a7d75f6ab3cc7f1b28e7deb0462efb155878e4e87ce3cc6e42ce35bea61c6fe4 + drb (2.2.1) sha256=e9d472bf785f558b96b25358bae115646da0dbfd45107ad858b0bc0d935cb340 + dry-initializer (3.1.1) sha256=4d267dea367ccabe498b259c62b909b99d577d6db547d9510561999403546dec + email_validator (2.2.4) sha256=5ab238095bec7aef9389f230e9e0c64c5081cdf91f19d6c5cecee0a93af20604 + erubi (1.13.0) sha256=fca61b47daefd865d0fb50d168634f27ad40181867445badf6427c459c33cd62 + et-orbi (1.2.11) sha256=d26e868cc21db88280a9ec1a50aa3da5d267eb9b2037ba7b831d6c2731f5df64 + execjs (2.9.1) sha256=e8fd066f6df60c8e8fbebc32c6fb356b5212c77374e8416a9019ca4bb154dcfb + factory_bot (6.4.5) sha256=d71dd29bc95f0ec2bf27e3dd9b1b4d557bd534caca744663cb7db4bacf3198be + factory_bot_rails (6.4.3) sha256=ea73ceac1c0ff3dc11fff390bf2ea8a2604066525ed8ecd3b3bc2c267226dcc8 + faraday (2.12.0) sha256=d106d0b1bc2548a8c0c34619a657a5e4aeae2780f951b0e095c8f803bbbf4517 + faraday-follow_redirects (0.3.0) sha256=d92d975635e2c7fe525dd494fcd4b9bb7f0a4a0ec0d5f4c15c729530fdb807f9 + faraday-multipart (1.0.4) sha256=9012021ab57790f7d712f590b48d5f948b19b43cfa11ca83e6459f06090b0725 + faraday-net_http (3.3.0) sha256=93e6b0f679b1e8e358bcb4e983a52328dfc47ebbe6a232e4f9e8aba9c924e565 + faraday-restrict-ip-addresses (0.3.0) sha256=2db7f6da6f59fa73a9d4ffe96b2b46a4b2b2c4d177e44a1962c1921d32ec7279 + faraday-retry (2.2.1) sha256=4146fed14549c0580bf14591fca419a40717de0dd24f267a8ec2d9a728677608 + faraday_middleware-aws-sigv4 (1.0.1) sha256=a001ea4f687ca1c60bad8f2a627196905ce3dbf285e461dc153240e92eaabe8f + ffi (1.17.0) sha256=51630e43425078311c056ca75f961bb3bda1641ab36e44ad4c455e0b0e4a231c + ffi (1.17.0-aarch64-linux-gnu) sha256=228c8fb79e6b018a31c75414115a75ca65f74e8138b2c9c97d22041e4e12f2c1 + ffi (1.17.0-arm64-darwin) sha256=609c874e76614542c6d485b0576e42a7a38ffcdf086612f9a300c4ec3fcd0d12 + ffi (1.17.0-x86_64-darwin) sha256=fdcd48c69db3303ef95aec5c64d6275fcf9878a02c0bec0afddc506ceca0f56b + ffi (1.17.0-x86_64-linux-gnu) sha256=1015e59d5919dd6bbcb0704325b0bd639be664a79b1e2189943ceb18faa34198 + ffi (1.17.0-x86_64-linux-musl) sha256=6573299eedf8dd16668f8a435b72c4236b61bca0279bb73c811e3cbdb395e877 + ffi-compiler (1.3.2) sha256=a94f3d81d12caf5c5d4ecf13980a70d0aeaa72268f3b9cc13358bcc6509184a0 + fugit (1.11.1) sha256=e89485e7be22226d8e9c6da411664d0660284b4b1c08cacb540f505907869868 + gem_server_conformance (0.1.4) sha256=ee404d5405eabcb6f7ab440d2193177375481a77bfa0ec3106165dd6c8e733bb + get_process_mem (0.2.7) sha256=4afd3c3641dd6a817c09806c7d6d509d8a9984512ac38dea8b917426bbf77eba + globalid (1.2.1) sha256=70bf76711871f843dbba72beb8613229a49429d1866828476f9c9d6ccc327ce9 + good_job (3.99.1) sha256=7d3869d8a8ee8ef7048fee5d746f41c21987b7822c20038a2f773036bef0830a + google-protobuf (4.28.2) sha256=6841bb4566bc33fc2d59b4dd28bfd9308fc528545f21722e9f6e6fa566289c29 + google-protobuf (4.28.2-aarch64-linux) sha256=a2ea5fc997adeed17a135a9fc46d05de69a6e4bf90c023c5ff1ab071c28bceea + google-protobuf (4.28.2-arm64-darwin) sha256=fe43de80f4818900c734dfb9076251ef582d200d4a21985675fc6e1500364602 + google-protobuf (4.28.2-x86_64-darwin) sha256=e1247c97fd2fb882abc2b6aa2c25211dcf91769665d123358bff8177e9277f8e + google-protobuf (4.28.2-x86_64-linux) sha256=c7bb3952d150cb1a0544cef3e3a01ac87fdd190dbf7c26b2ff9e58629bab08ea + gravtastic (3.2.6) sha256=ef98abcecf7c402b61cff1ae7c50a2c6d97dd22bac21ea9b421ce05bc03d734f + groupdate (6.5.1) sha256=a0a78d051a510d61864fe987f00089d8f03739741eadc41e6ad4ea6f786da110 + hashdiff (1.1.1) sha256=c7966316726e0ceefe9f5c6aef107ebc3ccfef8b6db55fe3934f046b2cf0936a + hashie (5.0.0) sha256=9d6c4e51f2a36d4616cbc8a322d619a162d8f42815a792596039fc95595603da + heapy (0.2.0) sha256=74141e845d61ffc7c1e8bf8b127c8cf94544ec7a1181aec613288682543585ea + honeybadger (5.5.1) sha256=22350ccfc9f3bac4bf7c5c733d5263d21dc57adeba16f14054428d9cc63fb56d + htmlbeautifier (1.4.3) sha256=b43d08f7e2aa6ae1b5a6f0607b4ed8954c8d4a8e85fd2336f975dda1e4db385b + htmlentities (4.3.4) sha256=125a73c6c9f2d1b62100b7c3c401e3624441b663762afa7fe428476435a673da + http (5.2.0) sha256=b99ed3c65376e0fd8107647fbaf5a8ab4f66c347d1271fb74cea757e209c6115 + http-cookie (1.0.7) sha256=cb7a399f3344f720b8a0f3b0765f29b62608ebb9c3ce4abf4d6eeff2caf42253 + http-form_data (2.3.0) sha256=cc4eeb1361d9876821e31d7b1cf0b68f1cf874b201d27903480479d86448a5f3 + http_accept_language (2.1.1) sha256=0043f0d55a148cf45b604dbdd197cb36437133e990016c68c892d49dbea31634 + httparty (0.22.0) sha256=78652a5c9471cf0093d3b2083c2295c9c8f12b44c65112f1846af2b71430fa6c + i18n (1.14.6) sha256=dc229a74f5d181f09942dd60ab5d6e667f7392c4ee826f35096db36d1fe3614c + importmap-rails (2.0.1) sha256=e739a6e70c09f797688c6983fa79567ec1edc9becc30d55b3f7cc897b1825586 + inline_svg (1.10.0) sha256=5b652934236fd9f8adc61f3fd6e208b7ca3282698b19f28659971da84bf9a10f + io-console (0.7.2) sha256=f0dccff252f877a4f60d04a4dc6b442b185ebffb4b320ab69212a92b48a7a221 + irb (1.14.1) sha256=5975003b58d36efaf492380baa982ceedf5aed36967a4d5b40996bc5c66e80f8 + jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1 + job-iteration (1.5.1) sha256=1428ad5b308adbaae8776c16b7792a846eb1ad7f4ab3c6e0f9668dd2ab1179e5 + json (2.7.2) sha256=1898b5cbc81cd36c0fd4d0b7ad2682c39fb07c5ff682fc6265f678f550d4982c + json-jwt (1.16.6) sha256=ab451f9cd8743cecc4137f4170806046c1d8a6d4ee6e8570e0b5c958409b266c + jwt (2.7.1) sha256=07357cd2f180739b2f8184eda969e252d850ac996ed0a23f616e8ff0a90ae19b + kaminari (1.2.2) sha256=c4076ff9adccc6109408333f87b5c4abbda5e39dc464bd4c66d06d9f73442a3e + kaminari-actionview (1.2.2) sha256=1330f6fc8b59a4a4ef6a549ff8a224797289ebf7a3a503e8c1652535287cc909 + kaminari-activerecord (1.2.2) sha256=0dd3a67bab356a356f36b3b7236bcb81cef313095365befe8e98057dd2472430 + kaminari-core (1.2.2) sha256=3bd26fec7370645af40ca73b9426a448d09b8a8ba7afa9ba3c3e0d39cdbb83ff + language_server-protocol (3.17.0.3) sha256=3d5c58c02f44a20d972957a9febe386d7e7468ab3900ce6bd2b563dd910c6b3f + launchdarkly-server-sdk (8.7.1) sha256=38361f8f548a917bccf52844ad9cf666c67a4b02a647d4b582766f8725986533 + launchy (3.0.1) sha256=b7fa60bda0197cf57614e271a250a8ca1f6a34ab889a3c73f67ec5d57c8a7f2c + ld-eventsource (2.2.2) sha256=5ea087a6f06bbd8e325d2c1aaead50f37f13d025b952985739e9380a78a96beb + letter_opener (1.10.0) sha256=2ff33f2e3b5c3c26d1959be54b395c086ca6d44826e8bf41a14ff96fdf1bdbb2 + letter_opener_web (3.0.0) sha256=3f391efe0e8b9b24becfab5537dfb17a5cf5eb532038f947daab58cb4b749860 + libdatadog (11.0.0.1.0) sha256=44779762b21149be5e17ce599dc2e89f6a3c89a99262293dfafd96ddb110f7f1 + libdatadog (11.0.0.1.0-aarch64-linux) sha256=ff1e5870a44df49d056ebab9c544a4ad4a04ab541dbe3945d8be6981ff5b870f + libdatadog (11.0.0.1.0-x86_64-linux) sha256=f03b510d5b4f85febf640139282b9c3aeb88eb876dcccebc15f704c91c3e73ad + libddwaf (1.14.0.0.0) sha256=b91ea9675f7d79d1cd10dd6513e3706760ac442cb8902164fbcef79b7082a8fd + libddwaf (1.14.0.0.0-aarch64-linux) sha256=549f83ba339afb0e500bd9f4c43459e274d42f5736a18d347ba2007be026649c + libddwaf (1.14.0.0.0-arm64-darwin) sha256=cd3c84d58b8f77f93ebb17e6ceb1cfd257c8d3a3a1ea558a7fc14d92f697cc2b + libddwaf (1.14.0.0.0-x86_64-darwin) sha256=33017c057fd6b4948ef4577c4a13bc89d878541961b205309fac1e71516433ff + libddwaf (1.14.0.0.0-x86_64-linux) sha256=030640a4158ae05f9276cc1e09bfe9d19dda5af26703235cf17cf3752f1985b1 + listen (3.9.0) sha256=db9e4424e0e5834480385197c139cb6b0ae0ef28cc13310cfd1ca78377d59c67 + llhttp-ffi (0.5.0) sha256=496f40ad44bcbf99de02da1f26b1ad64e6593cd487b931508a86228e2a3af0fa + local_time (3.0.2) sha256=cb8abb2d56726ae285d00b0bd7f4c34bafc38c0c1cbc70ddc28f3a5661168d9d + logger (1.6.1) sha256=3ad9587ed3940bf7897ea64a673971415523f4f7d6b22c5e3af5219705669653 + loofah (2.22.0) sha256=10d76e070c86b12fec74b6a9515fd1940f4459198b991342d0a7897d86c372fe + lookbook (2.3.2) sha256=abcdefeb13d9150ab30ab8022a2f459db85cbd02e90a0e8e066b32f9048e160a + mail (2.8.1) sha256=ec3b9fadcf2b3755c78785cb17bc9a0ca9ee9857108a64b6f5cfc9c0b5bfc9ad + maintenance_tasks (2.8.0) sha256=7ee8aa37ab39c6c3a5f4637878c1a343cc296596742248112458b922968d4a16 + marcel (1.0.4) sha256=0d5649feb64b8f19f3d3468b96c680bae9746335d02194270287868a661516a4 + matrix (0.4.2) sha256=71083ccbd67a14a43bfa78d3e4dc0f4b503b9cc18e5b4b1d686dc0f9ef7c4cc0 + memory_profiler (1.1.0) sha256=79a17df7980a140c83c469785905409d3027ca614c42c086089d128b805aa8f8 + meta-tags (2.22.0) sha256=c04df1f39cfbf7d48e2ae955124d89a2429ab508fc936b9a8f9a8ce4d326e6ec + method_source (1.1.0) sha256=181301c9c45b731b4769bc81e8860e72f9161ad7d66dd99103c9ab84f560f5c5 + mini_histogram (0.3.1) sha256=6a114b504e4618b0e076cc672996036870f7cc6f16b8e5c25c0c637726d2dd94 + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + mini_portile2 (2.8.7) sha256=13eef5ab459bbfd33d61e539564ec25a9c2cf593b0a5ea6d4d7ef8c19b162ee0 + minitest (5.25.1) sha256=3db6795a80634def1cf86fda79d2d83b59b25ce5e186fa675f73c565589d2ad8 + minitest-gcstats (1.3.1) sha256=cb25490f93aac02e3a5ff307e560d41afcdcafa7952c1c32efdeb9886b1f4711 + minitest-reporters (1.7.1) sha256=5060413a0c95b8c32fe73e0606f3631c173a884d7900e50013e15094eb50562c + minitest-retry (0.2.3) sha256=7b7f4896efb9b931a1acb442a40e5273c441f44946cf4c6a8eb8895838e7bf29 + mocha (2.4.5) sha256=b4c17e103a9b20ec9e21374f4e9be41006dbd20c5e7cb1c215f8ec56599a6112 + msgpack (1.7.2) sha256=59ab62fd8a4d0dfbde45009f87eb6f158ab2628a7c48886b0256f175166baaa8 + multi_json (1.15.0) sha256=1fd04138b6e4a90017e8d1b804c039031399866ff3fbabb7822aea367c78615d + multi_xml (0.7.1) sha256=4fce100c68af588ff91b8ba90a0bb3f0466f06c909f21a32f4962059140ba61b + multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8 + net-http (0.4.1) sha256=a96efc5ea18bcb9715e24dda4159d10f67ff0345c8a980d04630028055b2c282 + net-imap (0.4.15) sha256=e468121d50cfcf82b7bbcee823d352bbb62ba8add963daa0c08d21680d83b53d + net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 + net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 + net-smtp (0.5.0) sha256=5fc0415e6ea1cc0b3dfea7270438ec22b278ca8d524986a3ae4e5ae8d087b42a + nio4r (2.7.3) sha256=54b94cdd4b8f9dc39aaad5f699e97afae13efb44f2b180a6e724df76105ff604 + nokogiri (1.16.7) sha256=f819cbfdfb0a7b19c9c52c6f2ca63df0e58a6125f4f139707b586b9511d7fe95 + nokogiri (1.16.7-aarch64-linux) sha256=78778d35f165b59513be31c0fe232c63a82cf97626ffba695b5f822e5da1d74b + nokogiri (1.16.7-arm64-darwin) sha256=276dcea1b988a5b22b5acc1ba901d24b8e908c40b71dccd5d54a2ae279480dad + nokogiri (1.16.7-x86_64-darwin) sha256=630732b80fc572690eab50c73a1f18988f3ac401ed0b67ca9956ba2b1e2c3faa + nokogiri (1.16.7-x86_64-linux) sha256=9e1e428641d5942af877c60b418c71163560e9feb4a5c4015f3230a8b86a40f6 + oauth2 (2.0.9) sha256=b21f9defcf52dc1610e0dfab4c868342173dcd707fd15c777d9f4f04e153f7fb + observer (0.1.2) sha256=d8a3107131ba661138d748e7be3dbafc0d82e732fffba9fccb3d7829880950ac + octokit (9.1.0) sha256=7849a659d2722c629181f48d1d7e567c9539f1a85c9676144dbdbfc6ce288253 + omniauth (2.1.2) sha256=def03277298b8f8a5d3ff16cdb2eb5edb9bffed60ee7dda24cc0c89b3ae6a0ce + omniauth-github (2.0.1) sha256=8ff8e70ac6d6db9d52485eef52cfa894938c941496e66b52b5e2773ade3ccad4 + omniauth-oauth2 (1.8.0) sha256=b2f8e9559cc7e2d4efba57607691d6d2b634b879fc5b5b6ccfefa3da85089e78 + omniauth-rails_csrf_protection (1.0.2) sha256=1170fd672aff092b9b7ebebc1453559f073ed001e3ce62a1df616e32f8dc5fe0 + openid_connect (2.3.0) sha256=0dbb9cefeb11e0a65e706349266355bbbb060382ae138fc9e199ab1aa622744c + opensearch-ruby (3.4.0) sha256=0a8621686bed3c59b4c23e08cbaef873685a3fe4568e9d2703155ca92b8ca05d + openssl (3.2.0) sha256=3c4bb8760977b4becd2819c6c2569bcf5c6f48b32b9f7a4ce1fd37f996378d14 + openssl-signature_algorithm (1.3.0) sha256=a3b40b5e8276162d4a6e50c7c97cdaf1446f9b2c3946a6fa2c14628e0c957e80 + optimist (3.1.0) sha256=81886f53ee8919f330aa30076d320d88eef9bc85aae2275376b4afb007c69260 + pagy (8.6.3) sha256=537b2ee3119f237dd6c4a0d0a35c67a77b9d91ebb9d4f85e31407c2686774fb2 + parallel (1.26.3) sha256=d86babb7a2b814be9f4b81587bf0b6ce2da7d45969fab24d8ae4bf2bb4d4c7ef + parser (3.3.5.0) sha256=f30ebb71b7830c2e7cdc4b2b0e0ec2234900e3fca3fe2fba47f78be759181ab3 + pg (1.5.8) sha256=efd71939dd1d6d9f0b434dc61dee6ef64f1d2bfcd3c177598a8797c27e654f37 + pg_query (5.1.0) sha256=b7f7f47c864f08ccbed46a8244906fb6ee77ee344fd27250717963928c93145d + pghero (3.6.0) sha256=cad9cb865f99ff40bb5ba47d3dae20d06be06ac8ea6b01172f6a8ccc85671109 + phlex (1.10.2) sha256=49dca7df081258f937be5e4ee0a81b11743f2b4fea25ac7537912b9c9344b1e6 + phlex-rails (1.2.1) sha256=1d80709c02114cda869951d22bfca189b5f208d1eb89f2e6fafbe3c0240a822f + pp (0.5.0) sha256=f8f40bc2ba9e1ab351b9458151da3a89f46034f7f599a8e0a06abb9b9f4411dd + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prosopite (1.4.2) sha256=b2e422e2d9dbf3ce20130ded3252fe14adb3833555eacd451f5015d8c9177e79 + pry (0.14.1) sha256=99b6df0665875dd5a39d85e0150aa5a12e2bb4fef401b6c4f64d32ee502f8454 + pry-byebug (3.10.1) sha256=c8f975c32255bfdb29e151f5532130be64ff3d0042dc858d0907e849125581f8 + psych (5.1.2) sha256=337322f58fc2bf24827d2b9bd5ab595f6a72971867d151bb39980060ea40a368 + public_suffix (6.0.1) sha256=61d44e1cab5cbbbe5b31068481cf16976dd0dc1b6b07bd95617ef8c5e3e00c6f + puma (6.4.3) sha256=24a4645c006811d83f2480057d1f54a96e7627b6b90e1c99b260b9dc630eb43e + pundit (2.4.0) sha256=43e6d27a9df082c04f0020999ce4dcf6742ecc5775d102ef2bfe9df041417572 + pwned (2.3.0) sha256=63f5a9576f109203684e9dd053f815649fd5bc0a0348b7190568272641b22353 + raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882 + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rack (3.1.7) sha256=0e9982db4ea9013326788ca2a7f48e32a4e746765e7c3512d424ef0dd22ae58b + rack-attack (6.7.0) sha256=3ca47e8f66cd33b2c96af53ea4754525cd928ed3fa8da10ee6dad0277791d77c + rack-oauth2 (2.2.1) sha256=c73aa87c508043e2258f02b4fb110cacba9b37d2ccf884e22487d014a120d1a5 + rack-protection (4.0.0) sha256=d0db6185caa46a8c0d134c2c6b4803f4f392a67b2984da311409cb505f67bbf6 + rack-sanitizer (2.0.3) sha256=3e492e1b56b0005fb1b56d77dff996821059aad4321634883ae3c42c865f9af0 + rack-session (2.0.0) sha256=db04b2063e180369192a9046b4559af311990af38c6a93d4c600cee4eb6d4e81 + rack-test (2.1.0) sha256=0c61fc61904049d691922ea4bb99e28004ed3f43aa5cfd495024cc345f125dfb + rackup (2.1.0) sha256=6ecb884a581990332e45ee17bdfdc14ccbee46c2f710ae1566019907869a6c4d + rails (7.2.1) sha256=fd5684e5d007220960666a3a8b31a57cd1c8cd7f60d88cb40e28e95f1911b705 + rails-controller-testing (1.0.5) sha256=741448db59366073e86fc965ba403f881c636b79a2c39a48d0486f2607182e94 + rails-dom-testing (2.2.0) sha256=e515712e48df1f687a1d7c380fd7b07b8558faa26464474da64183a7426fa93b + rails-erd (1.7.2) sha256=0b17d0fba25d319d8da8af7a3e5e2149d02d6187cc7351e8be43423f07c48bcd + rails-html-sanitizer (1.6.0) sha256=86e9f19d2e6748890dcc2633c8945ca45baa08a1df9d8c215ce17b3b0afaa4de + rails-i18n (7.0.9) sha256=c184db80a7c7bf21c14e0e400fe9e27c4c20312f019aaff5b364a82858dc1369 + rails_semantic_logger (4.17.0) sha256=cc10cca01491736596cd5ab40b52b3bbf98338d9d1f5a7916b2be10231615047 + railties (7.2.1) sha256=4b6ad279bbfb9228d7e7fbc8df562a8f5d4910e179b957d801fcec176d548463 + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + rake (13.2.1) sha256=46cb38dae65d7d74b6020a4ac9d48afed8eb8149c040eccf0523bec91907059d + rb-fsevent (0.11.2) sha256=43900b972e7301d6570f64b850a5aa67833ee7d87b458ee92805d56b7318aefe + rb-inotify (0.10.1) sha256=050062d4f31d307cca52c3f6a7f4b946df8de25fc4bd373e1a5142e41034a7ca + rbtrace (0.5.1) sha256=e8cba64d462bfb8ba102d7be2ecaacc789247d52ac587d8003549d909cb9c5dc + rdoc (6.7.0) sha256=b17d5f0f57b0853d7b880d4360a32c7caf8dbb81f8503a36426df809e617f379 + redcarpet (3.6.0) sha256=8ad1889c0355ff4c47174af14edd06d62f45a326da1da6e8a121d59bdcd2e9e9 + regexp_parser (2.9.2) sha256=5a27e767ad634f8a4b544520d5cd28a0db7aa1198a5d7c9d7e11d7b3d9066446 + reline (0.5.10) sha256=1660c969a792ebd034e6ceee8ca628f3b6698dcdb34f7a282a5edda37b958166 + rexml (3.3.8) sha256=f68fd96345330a1062896d0d0401324b0dae0ee20f784791b0843db96b212167 + roadie (5.2.1) sha256=e4a4f61ce792bd91b228b6844b4bad6b160cdc1b8df86c81a8b983082a5001d6 + roadie-rails (3.2.0) sha256=90a534857fcfe9fdbe4f9ebfdbc47e5d33462c4f36f478fc80ba6a7b6257433f + rotp (6.3.0) sha256=75d40087e65ed0d8022c33055a6306c1c400d1c12261932533b5d6cbcd868854 + rouge (4.3.0) sha256=9ee3d9ec53338e78c03fff0cbcd08881d80d69152349b046761e48ccf2de581c + rqrcode (2.2.0) sha256=23eea88bb44c7ee6d6cab9354d08c287f7ebcdc6112e1fe7bcc2d010d1ffefc1 + rqrcode_core (1.2.0) sha256=cf4989dc82d24e2877984738c4ee569308625fed2a810960f1b02d68d0308d1a + rspec (3.13.0) sha256=d490914ac1d5a5a64a0e1400c1d54ddd2a501324d703b8cfe83f458337bab993 + rspec-core (3.13.0) sha256=557792b4e88da883d580342b263d9652b6a10a12d5bda9ef967b01a48f15454c + rspec-expectations (3.13.1) sha256=814cf8dadc797b00be55a84d7bc390c082735e5c914e62cbe8d0e19774b74200 + rspec-mocks (3.13.1) sha256=087189899c337937bcf1d66a50dc3fc999ac88335bbeba4d385c2a38c87d7b38 + rspec-support (3.13.1) sha256=48877d4f15b772b7538f3693c22225f2eda490ba65a0515c4e7cf6f2f17de70f + rubocop (1.66.1) sha256=0679c263b1164fd003b8590ae83b3e9e9bf72282d411755f227f1d6268ee5ee7 + rubocop-ast (1.32.3) sha256=40201e861c73a3c2d59428c7627828ef81fb2f8a306bc4a1c1801452afe3fe0f + rubocop-capybara (2.21.0) sha256=5d264efdd8b6c7081a3d4889decf1451a1cfaaec204d81534e236bc825b280ab + rubocop-factory_bot (2.26.1) sha256=8de13cd4edcee5ca800f255188167ecef8dbfc3d1fae9f15734e9d2e755392aa + rubocop-minitest (0.36.0) sha256=1d15850849c685ff4b6d64dd801ec2d13eb2fe56b6f7ce9aab93d1b0508e7b9f + rubocop-performance (1.22.1) sha256=9ed9737af1ee90655654b483e0eac4e64702139e85d33335bf744b57a309a679 + rubocop-rails (2.26.2) sha256=f5561a09d6afd2f54316f3f0f6057338ca55b6c24a25ba6a938d3ed0fded84ad + ruby-graphviz (1.2.5) sha256=1c2bb44e3f6da9e2b829f5e7fd8d75a521485fb6b4d1fc66ff0f93f906121504 + ruby-magic (0.6.0) sha256=7b2138877b7d23aff812c95564eba6473b74b815ef85beb0eb792e729a2b6101 + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + ruby-statistics (3.0.2) sha256=fb53e7a9f9681dac144c02539d3535fb2e8fae626f78b907219b0586ff53ec20 + ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef + rubyzip (2.3.2) sha256=3f57e3935dc2255c414484fbf8d673b4909d8a6a57007ed754dde39342d2373f + safety_net_attestation (0.4.0) sha256=96be2d74e7ed26453a51894913449bea0e072f44490021545ac2d1c38b0718ce + sass-embedded (1.72.0) sha256=05d3e53092022a4b4eef5b76b9597c7f2af4e4efb06812e1a60e3601189a3d2e + sass-embedded (1.72.0-aarch64-linux-gnu) sha256=cbe3b303bb2acf784a46e98cbb22b5218f1607524dc66c00f5bd3b32f029eae4 + sass-embedded (1.72.0-arm64-darwin) sha256=24bbb0fe3cb255070c0bd7d44ee330b1226bd02647a4390d6f50e706a3819a4a + sass-embedded (1.72.0-x86_64-darwin) sha256=f8b4940847cda523b418b8d1354dfb22464ca1f108d05be3e0d2bab7643d8757 + sass-embedded (1.72.0-x86_64-linux-gnu) sha256=33ecf2737eb685dc61853652312b0395c645b30578fc0d853d4b15f67cab1f3c + sass-embedded (1.72.0-x86_64-linux-musl) sha256=52161ecf9927251a463f8f9d34364940cfe881b47c10b57fdcb6f8e813c91995 + sassc-embedded (1.70.1) sha256=a95172c9c6725dfc412c702a0e705fb8a5bcb3aac2a32586b18e5432987103d3 + sawyer (0.9.2) sha256=fa3a72d62a4525517b18857ddb78926aab3424de0129be6772a8e2ba240e7aca + searchkick (5.4.0) sha256=75d7256d3ec2af2dc11c2ba8160c86d80451f3f86447aae2ace1f79553de0bf3 + securerandom (0.3.1) sha256=98f0450c0ea46d2f9a4b6db4f391dbd83dc08049592eada155739f40e0341bde + selenium-webdriver (4.25.0) sha256=7e11abf2b0fd56df61d98b6d59d621781cf103261d941df3510837547bd4a0d5 + semantic (1.6.1) sha256=3cdbb48f59198ebb782a3fdfb87b559e0822a311610db153bae22777a7d0c163 + semantic_logger (4.16.0) sha256=ffba0bd0e008ceaf6be26da588f610a61208b9a9f55676b32729e962904023d9 + shoryuken (6.2.1) sha256=95ddc0a717624a54e799d25a0a05100cb5a0c3728a96211935c214faaf16b3b6 + shoulda-context (3.0.0.rc1) sha256=6e0d9d52ab798c13bc2b490c8537d4bf30cfd318a1ea839c39a66d1d293c6a1a + shoulda-matchers (6.4.0) sha256=9055bb7f4bb342125fb860809798855c630e05ef5e75837b3168b8e6ee1608b0 + simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5 + simplecov-cobertura (2.1.0) sha256=2c6532e34df2e38a379d72cef9a05c3b16c64ce90566beebc6887801c4ad3f02 + simplecov-html (0.12.3) sha256=4b1aad33259ffba8b29c6876c12db70e5750cb9df829486e4c6e5da4fa0aa07b + simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428 + smart_properties (1.17.0) sha256=f9323f8122e932341756ddec8e0ac9ec6e238408a7661508be99439ca6d6384b + snaky_hash (2.0.1) sha256=1ac87ec157fcfe7a460e821e0cd48ae1e6f5e3e082ab520f03f31a9259dbdc31 + sprockets (4.2.1) sha256=951b13dd2f2fcae840a7184722689a803e0ff9d2702d902bd844b196da773f97 + sprockets-rails (3.5.2) sha256=a9e88e6ce9f8c912d349aa5401509165ec42326baf9e942a85de4b76dbc4119e + statsd-instrument (3.9.2) sha256=56c70080fb73fed02d9ce26bbf573850b16739ae4252ca508e855dfcfb08db86 + stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 + stringio (3.1.1) sha256=53456e14175c594e0e8eb2206a1be33f3974d4fe21c131e628908b05c8c2ae1e + strong_migrations (2.0.0) sha256=88750f294403e18ec674eda6901f2fc195f553ed6a7928c52e8a3f5b57ff501d + swd (2.0.3) sha256=4cdbe2a4246c19f093fce22e967ec3ebdd4657d37673672e621bf0c7eb770655 + tailwindcss-rails (2.7.9) sha256=227a80aecdc25465646e501b5b992dcde98799e9d58c44938b61ab8b62cccdb9 + tailwindcss-rails (2.7.9-aarch64-linux) sha256=e445c0761bcdc8fab423f35a9c6f2791ca73e46ef558a0e02601c9a2cf83dcb3 + tailwindcss-rails (2.7.9-arm64-darwin) sha256=9fd0e0f45e6695bf09c9275170d81ba47fdf6af4b7b6352d97467b7bb8e019f5 + tailwindcss-rails (2.7.9-x86_64-darwin) sha256=55a361c43ff8a65209e6d46f4d7dd901badb3b8a26ab603991789a2e5f4f79a9 + tailwindcss-rails (2.7.9-x86_64-linux) sha256=6ac0523506cb122e3e26bb07c2f6e150542530d0d13d8a4e8466ed7a69fbfce2 + terser (1.2.4) sha256=c85f46e0fcdeef4a52639cbb6dd6b359bf0151d035206b072366e7dac379cc83 + thor (1.3.2) sha256=eef0293b9e24158ccad7ab383ae83534b7ad4ed99c09f96f1a6b036550abbeda + tilt (2.3.0) sha256=82dd903d61213c63679d28e404ee8e10d1b0fdf5270f1ad0898ec314cc3e745c + timeout (0.4.1) sha256=6f1f4edd4bca28cffa59501733a94215407c6960bd2107331f0280d4abdebb9a + timescaledb (0.3.0) sha256=9ce2b39417d30544054cb609fbd84e18e304c7b7952a793846b8f4489551a28f + toxiproxy (2.0.2) sha256=2e3b53604fb921d40da3db8f78a52b3133fcae33e93d440725335b15974e440a + tpm-key_attestation (0.12.0) sha256=e133d80cf24fef0e7a7dfad00fd6aeff01fc79875fbfc66cd8537bbd622b1e6d + turbo-rails (1.5.0) sha256=b426cc762fb0940277729b3f1751a9f0bd269f5613c1d62ac73e5f0be7c7a83e + turbo_power (0.5.0) sha256=869726c3a4308b038d6244f7a80eb1331bdd3171fa320dddcff71899fcae24fb + tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b + unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a + unpwn (1.0.0) sha256=6239d17d46a882b3719b24fb79c78a34caff89d57ab0f5e546be5b5c882bc7d3 + uri (0.13.1) sha256=df2d8b13e3e8c8a43432637e2ace4f9de7b42674361b4dd26302b40f7d7fcd1e + user_agent_parser (2.18.0) sha256=aa943b91da8906cace7d3fe16b450c9d77b68f571485c11e577af97aecb25584 + useragent (0.16.10) sha256=1794380d9ea5c087d687bbfe14752f81839293f238c1132ef05c9344f09e65bb + validate_url (1.0.15) sha256=72fe164c0713d63a9970bd6700bea948babbfbdcec392f2342b6704042f57451 + validates_formatting_of (0.9.0) sha256=139590a4b87596dbfb04d93e897bd2e6d30fb849d04fab0343e71ed2ca856e7e + version_gem (1.1.1) sha256=3c2da6ded29045ddcc0387e152dc634e1f0c490b7128dce0697ccc1cf0915b6c + view_component (3.14.0) sha256=96816de1c40d276d9fac49316ee4d196de90b1ce6eb39373b887c639749e630c + webauthn (3.1.0) sha256=e545fcf17d8a6b821161a37c1c4bc8c3d2ead0ff6ff3b098f57f417e731790b7 + webfinger (2.1.3) sha256=567a52bde77fb38ca6b67e55db755f988766ec4651c1d24916a65aa70540695c + webmock (3.24.0) sha256=be01357f6fc773606337ca79f3ba332b7d52cbe5c27587671abc0572dbec7122 + webrick (1.8.2) sha256=431746a349199546ff9dd272cae10849c865f938216e41c402a6489248f12f21 + websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737 + websocket-driver (0.7.6) sha256=f69400be7bc197879726ad8e6f5869a61823147372fd8928836a53c2c741d0db + websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 + xml-simple (1.1.9) sha256=d21131e519c86f1a5bc2b6d2d57d46e6998e47f18ed249b25cad86433dbd695d + xpath (3.2.0) sha256=6dfda79d91bb3b949b947ecc5919f042ef2f399b904013eb3ef6d20dd3a4082e + yard (0.9.36) sha256=5505736c1b00c926f71053a606ab75f02070c5960d0778b901fe9d8b0a470be4 + zeitwerk (2.7.0) sha256=a44c846861f2dfb571b834110a38f610dae5f15bac7ebf642a0d46b723da43a4 + zlib (3.1.1) sha256=f61bb03139bbe256c36ba99ef9fece1fb223e9034ed9e5fa3ddb1588d99abc71 + +RUBY VERSION + ruby 3.3.5p100 BUNDLED WITH - 2.1.4 + 2.5.21 diff --git a/README.md b/README.md index 7c9a62a807b..792fe98d105 100644 --- a/README.md +++ b/README.md @@ -9,33 +9,22 @@ The Ruby community's gem host. ## Support -
-[RubyGems.org](https://rubygems.org) is managed by [Ruby Central](https://rubycentral.org), a community-funded organization supported by conference participation for [RailsConf](https://railsconf.org) and [RubyConf](https://rubyconf.org) through tickets and sponsorships. +[RubyGems.org](https://rubygems.org) is managed by [Ruby Central](https://rubycentral.org), a non-profit organization that supports the Ruby community through projects like this one, as well as [RubyConf](https://rubyconf.org), [RailsConf](https://railsconf.org), and [Bundler](https://bundler.io). You can support Ruby Central by attending or [sponsoring](sponsors@rubycentral.org) a conference, or by [joining as a supporting member](https://rubycentral.org/#/portal/signup). -Hosting fees are paid by Ruby Central and CDN fees are generously donated by [Fastly](https://fastly.com). - -Additionally, [RubyTogether](https://rubytogether.org) sponsors individuals to work on development and operations work for RubyGems.org which augments volunteer efforts from the Ruby community. +Hosting is donated by [Amazon Web Services](https://aws.amazon.com), with CDN service donated by [Fastly](https://fastly.com). [Learn more about our sponsors and how they work together.](https://rubygems.org/pages/sponsors) ## Links -* [Mailing List][] -* [FAQ][] -* [IRC][]: #rubygems on Freenode -* [Travis][]: [![Build Status](https://img.shields.io/travis/rubygems/rubygems.org/master.svg)][travis] -* [Code Climate][]: [![Maintainability](https://api.codeclimate.com/v1/badges/7110bb3f9b765042d604/maintainability)](https://codeclimate.com/github/rubygems/rubygems.org/maintainability) -* [Code Climate][]: [![Test Coverage](https://api.codeclimate.com/v1/badges/7110bb3f9b765042d604/test_coverage)](https://codeclimate.com/github/rubygems/rubygems.org/test_coverage) -* [Trello Board][] - -[mailing list]: https://groups.google.com/group/rubygems-org -[faq]: https://help.rubygems.org/kb/gemcutter/faq -[irc]: https://webchat.freenode.net/?channels=rubygems -[travis]: https://travis-ci.org/rubygems/rubygems.org -[code climate]: https://codeclimate.com/github/rubygems/rubygems.org -[trello board]: https://trello.com/board/rubygems-org/513f9634a7ed906115000755 +* [Slack](https://bundler.slack.com/) +* [RFCs](https://github.com/rubygems/rfcs) +* [Support](mailto:support@rubygems.org) +* [GitHub Workflow][]: [![test workflow](https://github.com/rubygems/rubygems.org/actions/workflows/test.yml/badge.svg)](https://github.com/rubygems/rubygems.org/actions/workflows/test.yml) [![lint workflow](https://github.com/rubygems/rubygems.org/actions/workflows/lint.yml/badge.svg)](https://github.com/rubygems/rubygems.org/actions/workflows/lint.yml) [![docker workflow](https://github.com/rubygems/rubygems.org/actions/workflows/docker.yml/badge.svg)](https://github.com/rubygems/rubygems.org/actions/workflows/docker.yml) + +[github workflow]: https://github.com/rubygems/rubygems.org/actions/ ## Contributions diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000000..10e8179ac09 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,7 @@ +# Security Policy + +We deeply appreciate any effort to discover and disclose security vulnerabilities responsibly. + +For any security bug or issue with the RubyGems client or RubyGems.org service, please email security@rubygems.org with details about the problem or submit a report using [HackerOne](https://hackerone.com/rubygems). + +For additional information about RubyGems security, please see https://rubygems.org/pages/security. diff --git a/app/assets/builds/.keep b/app/assets/builds/.keep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js index a077e1f5377..2b38b4b8101 100644 --- a/app/assets/config/manifest.js +++ b/app/assets/config/manifest.js @@ -1,3 +1,6 @@ //= link application.css -//= link application.js //= link_tree ../../../vendor/assets/images +//= link_tree ../builds +//= link_tree ../../javascript .js +//= link_tree ../../javascript/src .js +//= link_tree ../../../vendor/javascript .js diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js deleted file mode 100644 index 19a326cecd8..00000000000 --- a/app/assets/javascripts/application.js +++ /dev/null @@ -1,23 +0,0 @@ -// This is a manifest file that'll be compiled into including all the files listed below. -// Add new JavaScript/Coffee code in separate files in this directory and they'll automatically -// be included in the compiled file accessible from http://example.com/assets/application.js -// It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the -// the compiled file. -// -//= require jquery3 -//= require jquery_ujs -//= require clipboard -//= require github_buttons -//= require_tree . - -function handleClick(event, nav, removeNavExpandedClass, addNavExpandedClass) { - var isMobileNavExpanded = nav.popUp.hasClass(nav.expandedClass); - - event.preventDefault(); - - if (isMobileNavExpanded) { - removeNavExpandedClass(); - } else { - addNavExpandedClass(); - } -} diff --git a/app/assets/javascripts/clipboard_buttons.js b/app/assets/javascripts/clipboard_buttons.js deleted file mode 100644 index 35e05b14c69..00000000000 --- a/app/assets/javascripts/clipboard_buttons.js +++ /dev/null @@ -1,35 +0,0 @@ -$(function() { - var clipboard = new Clipboard('.gem__code__icon'); - var copyTooltip = $('.gem__code__tooltip--copy'); - var copiedTooltip = $('.gem__code__tooltip--copied'); - var copyButtons = $('.gem__code__icon'); - - function hideCopyShowCopiedTooltips(e) { - copyTooltip.removeClass("clipboard-is-hover"); - copiedTooltip.insertAfter(e.trigger); - copiedTooltip.addClass("clipboard-is-active"); - }; - - clipboard.on('success', function(e) { - hideCopyShowCopiedTooltips(e); - e.clearSelection(); - }); - - clipboard.on('error', function(e) { - hideCopyShowCopiedTooltips(e); - copiedTooltip.text("Ctrl-C to Copy"); - }); - - copyButtons.hover(function() { - copyTooltip.insertAfter(this); - copyTooltip.addClass("clipboard-is-hover"); - }); - - copyButtons.mouseout(function() { - copyTooltip.removeClass("clipboard-is-hover"); - }); - - copyButtons.mouseout(function() { - copiedTooltip.removeClass("clipboard-is-active"); - }); -}); diff --git a/app/assets/javascripts/mobile-nav.js b/app/assets/javascripts/mobile-nav.js deleted file mode 100644 index 445ab1d8dff..00000000000 --- a/app/assets/javascripts/mobile-nav.js +++ /dev/null @@ -1,67 +0,0 @@ -$(function() { - // cache jQuery lookups into variables - // so we don't have to traverse the DOM every time - var sandwichIcon = $('.header__club-sandwich'); - var header = $('.header'); - var main = $('main'); - var footer = $('.footer'); - var signUpLink = $('.header__nav-link.js-sign-up-trigger'); - var navExpandedClass = 'mobile-nav-is-expanded'; - var headerSearch = $('.header__search'); - var headerLogo = $('.header__logo-wrap'); - - // variable to support mobile nav tab behaviour - // * skipSandwichIcon is for skipping sandwich icon - // when you tab from "gem" icon - // * tabDirection is for hiding and showing navbar - // when you tab in and out - var skipSandwichIcon = true; - var tabDirection = true; - - function removeNavExpandedClass() { - header.removeClass(navExpandedClass); - main.removeClass(navExpandedClass); - footer.removeClass(navExpandedClass); - } - - function addNavExpandedClass() { - header.addClass(navExpandedClass); - main.addClass(navExpandedClass); - footer.addClass(navExpandedClass); - } - - function handleFocusIn() { - if (skipSandwichIcon) { - addNavExpandedClass(); - headerSearch.focus(); - skipSandwichIcon = false; - } else { - removeNavExpandedClass(); - headerLogo.focus(); - skipSandwichIcon = true; - } - } - - sandwichIcon.click(function(e){ - var nav = {expandedClass: navExpandedClass, popUp: header} - handleClick(e, nav, removeNavExpandedClass, addNavExpandedClass); - }); - - sandwichIcon.on('focusin', handleFocusIn); - - signUpLink.on('focusin', function() { - if (!tabDirection) { - addNavExpandedClass(); - } - }); - - signUpLink.on('focusout', function() { - if (tabDirection) { - tabDirection = false; - removeNavExpandedClass(); - } else { - tabDirection = true; - addNavExpandedClass(); - } - }); -}); diff --git a/app/assets/javascripts/multifactor_auths.js b/app/assets/javascripts/multifactor_auths.js deleted file mode 100644 index 3224a242be3..00000000000 --- a/app/assets/javascripts/multifactor_auths.js +++ /dev/null @@ -1,6 +0,0 @@ -if($("#recovery-code-list").length){ - window.addEventListener("beforeunload", function (e) { - e.preventDefault(); - e.returnValue = ''; - }); -} diff --git a/app/assets/javascripts/pages.js b/app/assets/javascripts/pages.js deleted file mode 100644 index a65eca563d9..00000000000 --- a/app/assets/javascripts/pages.js +++ /dev/null @@ -1,75 +0,0 @@ -//data page -$(document).ready(function() { - var getDumpData = function(target, type) { - return $.get('https://s3-us-west-2.amazonaws.com/rubygems-dumps/?prefix=production/public_' + type).done(function(data) { - var files, xml; - xml = $(data); - files = parseS3Listing(xml); - files = sortByLastModified(files); - $(target).html(renderDumpList(files)); - }).fail(function(error) { - console.error(error); - }); - }; - - var parseS3Listing = function(xml) { - var files; - files = $.map(xml.find('Contents'), function(item) { - item = $(item); - return { - Key: item.find('Key').text(), - LastModified: item.find('LastModified').text(), - Size: item.find('Size').text(), - StorageClass: item.find('StorageClass').text() - }; - }); - return files; - }; - - var sortByLastModified = function(files) { - return files.sort(function(a, b) {return Date.parse(b.LastModified) - Date.parse(a.LastModified)}); - }; - - var bytesToSize = function(bytes) { - var i, k, sizes; - if (bytes === 0) { - return '0 Byte'; - } - k = 1024; - sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; - i = Math.floor(Math.log(bytes) / Math.log(k)); - return (bytes / Math.pow(k, i)).toPrecision(3) + " " + sizes[i]; - }; - - var renderDumpList = function(files) { - var content; - content = []; - jQuery.each(files, function(idx, item) { - if ('STANDARD' === item.StorageClass) { - return content.push("
  • " + (item.LastModified.replace('.000Z', '')) + " (" + (bytesToSize(item.Size)) + ")
  • "); - } - }); - return content.join("\n"); - }; - - if($("#data-dump").length) { - getDumpData('ul.rubygems-dump-listing-postgresql', 'postgresql'); - getDumpData('ul.rubygems-dump-listing-redis', 'redis'); - } -}); - -//stats page -$('.stats__graph__gem__meter').each(function() { - bar_width = $(this).data("bar_width"); - $(this).animate({ width: bar_width + '%' }, 700).removeClass('t-item--hidden').css("display", "block"); -}); - -//gem page -$(document).ready(function() { - $('.gem__users__mfa-text.mfa-warn').on('click', function() { - $('.gem__users__mfa-text.mfa-warn').toggleClass('t-item--hidden'); - - $owners = $('.gem__users__mfa-disabled'); - $owners.toggleClass('t-item--hidden'); - }); -}); diff --git a/app/assets/javascripts/popup-nav.js b/app/assets/javascripts/popup-nav.js deleted file mode 100644 index ed86b3d6735..00000000000 --- a/app/assets/javascripts/popup-nav.js +++ /dev/null @@ -1,20 +0,0 @@ -$(function() { - var arrowIcon = $('.header__popup-link'); - var popupNav = $('.header__popup__nav-links'); - - var navExpandedClass = 'is-expanded'; - - function removeNavExpandedClass() { - popupNav.removeClass(navExpandedClass); - } - - function addNavExpandedClass() { - popupNav.addClass(navExpandedClass); - } - - arrowIcon.click(function(e){ - var nav = {expandedClass: navExpandedClass, popUp: popupNav} - handleClick(e, nav, removeNavExpandedClass, addNavExpandedClass); - }); -}); - diff --git a/app/assets/javascripts/search.js b/app/assets/javascripts/search.js deleted file mode 100644 index 60833246081..00000000000 --- a/app/assets/javascripts/search.js +++ /dev/null @@ -1,22 +0,0 @@ -if($("#advanced-search").length){ - var $main = $('#home_query'); - var $name = $('input#name'); - var $summary = $('input#summary'); - var $description = $('input#description'); - var $downloads = $('input#downloads'); - var $updated = $('input#updated'); - - $name.add($summary) - .add($description) - .add($downloads) - .add($updated) - .on('input', function(e) { - var name = $name.val().length > 0 ? 'name: ' + $name.val() : ''; - var summary = $summary.val().length > 0 ? 'summary: ' + $summary.val() : ''; - var description = $description.val().length > 0 ? 'description: ' + $description.val() : ''; - var downloads = $downloads.val().length > 0 ? 'downloads: ' + $downloads.val() : ''; - var updated = $updated.val().length > 0 ? 'updated: ' + $updated.val() : ''; - - $main.val($.trim(name + ' ' + summary + ' ' + description + ' ' + downloads + ' ' + updated)); - }); -} diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css new file mode 100644 index 00000000000..f575b6d8902 --- /dev/null +++ b/app/assets/stylesheets/application.tailwind.css @@ -0,0 +1,14 @@ +@config "../../../config/tailwind.config.js"; +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + @font-face { + font-family: "Titillium Web"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/fonts/Roboto.woff2) format('woff2'); + } +} diff --git a/app/assets/stylesheets/layout.css b/app/assets/stylesheets/layout.css index d52dfa27802..e35045439d5 100644 --- a/app/assets/stylesheets/layout.css +++ b/app/assets/stylesheets/layout.css @@ -1,6 +1,37 @@ .l-relative { position: relative; } +.l-flex { + display: flex; } + + .l-flex-child { + flex: 1; + } + +.l-items-center { + align-items: center; } + +.l-gap-2 { + gap: 0.5rem; } + +.l-mr-4 { + margin-right: 1rem; } + +.l-mb-0 { + margin-bottom: 0 !important; } + +.l-mb-4 { + margin-bottom: 1rem; } + +.l-mb-8 { + margin-bottom: 2rem; } + +.l-text-red-600 { + color: #e53e3e; } + +.l-text-red-600 a { + text-decoration: underline; } + .l-wrap--b { margin-right: auto; margin-left: auto; diff --git a/app/assets/stylesheets/modules/badge.css b/app/assets/stylesheets/modules/badge.css new file mode 100644 index 00000000000..1b4c1f3d73d --- /dev/null +++ b/app/assets/stylesheets/modules/badge.css @@ -0,0 +1,20 @@ +.badge { + display: inline; + background-color: #e1e1e1; + color: black; + border: 1px solid #c0c0c0; + border-radius: 5px; + padding: 5px 10px; + text-transform: uppercase; + font-size: 12px; +} + +.badge--success { + background-color: #c4f2c7; + border-color: #9ecea1; +} + +.badge--warning { + background-color: #fff6d2; + border-color: #dcd3b1; +} diff --git a/app/assets/stylesheets/modules/button.css b/app/assets/stylesheets/modules/button.css index fffa9ee12dc..357b209924c 100644 --- a/app/assets/stylesheets/modules/button.css +++ b/app/assets/stylesheets/modules/button.css @@ -12,18 +12,11 @@ outline: none; transition-duration: 0.25s; transition-property: border-color, background-color; } -@-moz-document url-prefix() { - .home__join, .form__submit { - padding-top: 12px; - padding-bottom: 8px; } } .home__join:before { position: absolute; right: 18px; line-height: 22px; } -@-moz-document url-prefix() { - .home__join:before { - line-height: 16px; } } .home__join:focus:before, .home__join:hover:before { animation: arrow .75s infinite; } @-ms-keyframes arrow { @@ -64,6 +57,13 @@ .form__submit:hover { opacity: .7; } +.form__submit:disabled { + opacity: .7; } + +.form__submit--no-hover:enabled:hover { + opacity: 1; +} + .form__submit--small { margin: auto; padding: 4px 25px 4px 25px; @@ -78,6 +78,20 @@ } } +.form__submit--medium { + padding: 4px 25px 6px 25px; + font-weight: 600; + text-align: center; + margin: 20px 20px 0 0; + float: left; + width: auto; + min-width: 150px; +} + +.form--inline { + display: inline; +} + .download__format { margin-bottom: 24px; padding-top: 10px; diff --git a/app/assets/stylesheets/modules/footer.css b/app/assets/stylesheets/modules/footer.css index 49ffa26e0be..b544f3fc0cc 100644 --- a/app/assets/stylesheets/modules/footer.css +++ b/app/assets/stylesheets/modules/footer.css @@ -53,30 +53,17 @@ display: inline-block; } -.footer__sponsors-wrap { - margin-top: 60px; +.footer__sponsors { + margin: 60px auto 0; padding-top: 45px; + width: 90%; + max-width: 940px; + text-align: center; border-top: 1px solid rgba(255, 255, 255, 0.1); } - @media (min-width: 1040px) { - .footer__sponsors-wrap { - margin-right: auto; - margin-left: auto; - max-width: 940px; } } - -.footer__sponsors { - margin: 0 2%;} - @media (max-width: 579px) { - .footer__sponsors { - margin: 0 10%; - width: 78%; } } - @media (min-width: 580px) and (max-width: 1039px) { - .footer__sponsors { - margin: 0 10%; - width: 90%; } } .footer__sponsor { - margin-bottom: 36px; - float: left; + margin: 0 26px 36px; + display: inline-block; height: 100px; width: 90px; background-image: url(/sponsors.png); @@ -93,64 +80,24 @@ opacity: 1; } .footer__sponsor:focus { outline: none; } - .footer__sponsor:nth-child(1) { + .footer__sponsor__ruby_central { background-position: 0 4px; } - @media (max-width: 1039px) { - .footer__sponsor { - width: 100px; } - .footer__sponsor:nth-child(1) { - background-position: 0 -6px; } - .footer__sponsor:nth-child(2) { - background-position: 0 -119px; } - .footer__sponsor:nth-child(3) { - background-position: 0 -232px; } - .footer__sponsor:nth-child(4) { - background-position: 0 -330px; } - .footer__sponsor:nth-child(5) { - background-position: 0 -419px; } - .footer__sponsor:nth-child(6) { - background-position: 0 -710px; } - .footer__sponsor:nth-child(7) { - background-position: 0 -526px; } - .footer__sponsor:nth-child(8) { - background-position: 0 -606px; } - .footer__sponsor:nth-child(9) { - background-position: 0 -804px; } - .footer__sponsor:nth-child(10) { - background-position: 0 -950px; } - .footer__sponsor:nth-child(11) { - background-position: 0 -1080px; } - } - @media (min-width: 1040px) { - .footer__sponsor { - width: 90px; } - .footer__sponsor:nth-child(2) { - background-position: 0 -105px; } - .footer__sponsor:nth-child(3) { - background-position: 0 -204px; } - .footer__sponsor:nth-child(4) { - background-position: 0 -291px; } - .footer__sponsor:nth-child(5) { - background-position: 0 -372px; } - .footer__sponsor:nth-child(6) { - background-position: 0 -634px; } - .footer__sponsor:nth-child(7) { - background-position: 0 -469px; } - .footer__sponsor:nth-child(8) { - background-position: 0 -541px; } - .footer__sponsor:nth-child(9) { - background-position: 0 -717px; } - .footer__sponsor:nth-child(10) { - background-position: 0 -852px; } - .footer__sponsor:nth-child(11) { - background-position: 0 -967px; } - } - @media (max-width: 579px) { - .footer__sponsor:nth-child(odd) { - margin-right: 18%; } } - @media (min-width: 580px) { - .footer__sponsor { - margin:0 30px 36px 30px; } } + .footer__sponsor__dockyard { + background-position: 0 -204px; } + .footer__sponsor__dnsimple { + background-position: 0 -291px; } + .footer__sponsor__datadog { + background-position: 0 -624px; } + .footer__sponsor__runscope { + background-position: 0 -469px; } + .footer__sponsor__fastly { + background-position: 0 -541px; } + .footer__sponsor__honeybadger { + background-position: 0 -717px; } + .footer__sponsor__domainr { + background-position: 0 -804px; } + .footer__sponsor__whitesource { + background-position: 0 -885px; } .footer__sponsor__logo { margin-top: 5px; diff --git a/app/assets/stylesheets/modules/form.css b/app/assets/stylesheets/modules/form.css index 596974c6711..2753a3e0734 100644 --- a/app/assets/stylesheets/modules/form.css +++ b/app/assets/stylesheets/modules/form.css @@ -17,11 +17,11 @@ .form__label__icon { position: absolute; - top: -4px; - height: 30px; } + top: -1px; + height: 20px; } .form__label__icon-container .form__label__text { - padding-left: 33px;} + padding-left: 27px;} .form__input__addon-container { position: relative; } @@ -41,14 +41,21 @@ .form__input__addon-left .form__input { padding-left: 45px; } -.form__input, .form__textarea, .form__select { +.form__nested_fields, .form__input, .form__textarea, .form__select, .form__group { margin-bottom: 30px; } -.form__input, .form__textarea { - -webkit-appearance: none; - padding: 12px 50px 12px 16px; +.form__nested_fields, .form__input, .form__textarea { display: block; width: 100%; +} + +.form__nested_fields { + margin: 12px 0 12px 32px; + width: calc(100% - 32px); } + +.form__input, .form__textarea { + -webkit-appearance: none; + padding: 12px 16px; font-weight: 300; font-size: 18px; border: 1px solid #f2f3f4; @@ -73,7 +80,9 @@ max-width: 320px; } .form__textarea { - max-width: 640px; + margin-top: 5px; + max-width: 920px; + height: 300px; resize: none; } .form__select-wrap { @@ -173,8 +182,8 @@ margin-right: 10px; height: 26px; width: 26px; - border: 1px solid #f2f3f4; - border: 0 none #f2f3f4 \0; + border: 1px solid #7c7e80; + border: 0 none #7c7e80 \0; border-radius: 4px; border-radius: 0\0; box-shadow: inset 0 1px 1px -1px #c1c4ca; @@ -217,3 +226,25 @@ .form__checkbox__item .field_with_errors { display: contents; } + +.form__flex_group { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + flex-direction: row; + flex-wrap: wrap; + align-items: baseline; + gap: 20px; +} + +.form__flex_group > .form__submit { + margin: initial; + width: initial; + align-self: center; +} + +.form__scope_checkbox_grid_group { + display: grid; + grid-template-columns: repeat(auto-fill, 200px); + grid-gap: 20px; +} diff --git a/app/assets/stylesheets/modules/gem.css b/app/assets/stylesheets/modules/gem.css index d3a6e6c23a3..cf8c3d54c7a 100644 --- a/app/assets/stylesheets/modules/gem.css +++ b/app/assets/stylesheets/modules/gem.css @@ -1,6 +1,10 @@ +:root { + --gem-version-color: #a6aab2; +} + .gem__version { position: relative; - color: #a6aab2; } + color: var(--gem-version-color); } @media (max-width: 929px) { .gem__version { top: 5px; @@ -10,7 +14,7 @@ top: 10px; font-size: 42px; } } -.gem__intro { +.gem__intro, .gem__install { margin-bottom: 32px; padding-bottom: 30px; border-bottom: 1px solid #c1c4ca; } @@ -38,6 +42,9 @@ .gem__sha { margin-top: 16px; } +.gem__expiry { + margin-top: 16px; } + .gem__ghbtn { margin-bottom: 15px; } @@ -59,7 +66,6 @@ opacity: .5; } .gem__users__mfa-text { - font-size: 9px; color: #e9573f; } .gem__downloads-wrap { @@ -90,6 +96,14 @@ font-size: 18px; color: #141c22; } +.gem__rubygem-version-age { + margin-top: 10px; + margin-bottom: 30px; + display: block; + font-weight: 800; + font-size: 14px; + color: #141c22; } + .gem__code-wrap { margin-top: 12px; position: relative; @@ -122,6 +136,11 @@ border: none; font-weight: bold; } +.gem__code.multiline { + line-height: inherit; + white-space: pre-wrap; + border-radius: 0; } + .gem__code::-webkit-scrollbar { display: none; } @@ -149,43 +168,33 @@ width: 10px; background-image: linear-gradient(to right, transparent 0%, white 100%); } -.gem__code__tooltip--copy, -.gem__code__tooltip--copied { - display: none; } - -.clipboard-is-hover, -.clipboard-is-active { - display: block; - position: absolute; - top: 45px; - right: 0; - width: auto; - padding-left: 10px; - padding-right: 10px; - z-index: 1; - border-radius: 6px; - background-color: #141c22; - text-transform: none; - line-height: 30px; - text-align: center; - color: #ffffff; } - .clipboard-is-hover:before, - .clipboard-is-active:before { - content: ""; - position: absolute; - top: -4px; - right: 8px; - height: 15px; - width: 15px; - background-color: #141c22; - -ms-transform: rotate(45deg); - -webkit-transform: rotate(45deg); - transform: rotate(45deg); - } +.gem__code__header { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + padding: .5rem; + border-top-left-radius: .25rem; + border-top-right-radius: .25rem; + border: #c1c4ca 1px solid; + border-bottom: 0; } + +.gem__code__header .gem__code__icon { + position: inherit; + padding: .125rem; + width: 40px; } + +.gem__code__icon.static { + position: static; } .gem__link:before { margin-right: 16px; } +.gem__link__verified:after { + margin-left: 8px; + font-size: .75rem; + content: "✓"; } + .gem__versions-wrap { margin-bottom: 70px; overflow: auto; } @@ -194,7 +203,10 @@ margin-bottom: 12px; } .gem__version__date { - color: #a6aab2; } + color: var(--gem-version-color); } + +.gem__version__date.tooltip__text { + color: var(--gem-version-color); } .gem__versions-wrap { overflow: auto; } @@ -209,11 +221,13 @@ .gem__version-wrap .gem__version__date { font-weight: 400; } +.gem__requirement-wrap { + line-height: inherit; } .gem__requirement-wrap a.t-list__item { display: inline-block; } .gem__unregistered { - color: #a6aab2; + color: var(--gem-version-color); cursor: help; } @@ -247,3 +261,7 @@ .gem__previous__version { float: left; } + +.gem__dependencies:not(:first-of-type) { + margin-top: 36px; +} diff --git a/app/assets/stylesheets/modules/header.css b/app/assets/stylesheets/modules/header.css index dcd4944f3fd..1c1deb0a14a 100644 --- a/app/assets/stylesheets/modules/header.css +++ b/app/assets/stylesheets/modules/header.css @@ -12,6 +12,8 @@ margin-left: auto; } } .header { + position: relative; + z-index: 1; -webkit-transform: translateZ(0); } @media (max-width: 1019px) { .header { @@ -185,25 +187,25 @@ border-radius: 22px; } @media (min-width: 1020px) { - .header__popup__nav-links, .home__header__popup__nav-links { + .header__popup__nav-links.hidden { display: none; } - .header__popup__nav-links.is-expanded, .home__header__popup__nav-links.is-expanded { - margin-top: -7px; - display: block; - float: right; - position: absolute; - right: 0; - text-align: right; } - .header__popup__nav-links .header__nav-link, .home__header__popup__nav-links .header__nav-link { - margin-left: 0; - padding-top: 15px; - padding-right: 30px; - padding-bottom: 15px; - display: block; - width: 100%; } } + .header__popup__nav-links { + margin-top: -7px; + display: block; + float: right; + position: absolute; + right: 0; + text-align: right; } + .header__popup__nav-links .header__nav-link { + margin-left: 0; + padding-top: 15px; + padding-right: 30px; + padding-bottom: 15px; + display: block; + width: 100%; } } @media (min-width: 1020px) { - .header__popup__nav-links.is-expanded { + .header__popup__nav-links { width: 180px; border-bottom-right-radius: 3px; border-bottom-left-radius: 3px; @@ -213,7 +215,7 @@ border-bottom: 1px solid rgba(255, 255, 255, 0.3); } } @media (min-width: 1020px) { - .body--index .header__popup__nav-links.is-expanded { + .body--index .header__popup__nav-links { width: 150px; top: 0; padding-top: 74px; diff --git a/app/assets/stylesheets/modules/home.css b/app/assets/stylesheets/modules/home.css index c98b5f295b8..0201cd1b404 100644 --- a/app/assets/stylesheets/modules/home.css +++ b/app/assets/stylesheets/modules/home.css @@ -152,3 +152,24 @@ font-size: 16px; } .home__link:focus { outline: none; } + +/* Banner */ + +.banner { + background-color: #141c22; + text-align: center; + padding: 15px; + color: white; +} + +@media (max-width: 899px) { + .banner { + font-size: 12px; + } +} + +.banner a { + color: #e9573f; + text-decoration: underline; + font-weight: bold; +} diff --git a/app/assets/stylesheets/modules/mfa.css b/app/assets/stylesheets/modules/mfa.css new file mode 100644 index 00000000000..ceb2718c1fd --- /dev/null +++ b/app/assets/stylesheets/modules/mfa.css @@ -0,0 +1,25 @@ +.mfa__container { + display: flex; + flex-direction: column; +} + +.mfa__option { + flex: 1; +} + +.mfa__header-wrapper { + display: flex; + align-items: center; + gap: 20px; +} + +.mfa__header.mfa__header--compact { + padding: 0; +} + +@media (min-width: 780px) { + .mfa__container { + flex-direction: row; + gap: 1rem; + } +} diff --git a/app/assets/stylesheets/modules/oidc.css b/app/assets/stylesheets/modules/oidc.css new file mode 100644 index 00000000000..cbe48a95a4b --- /dev/null +++ b/app/assets/stylesheets/modules/oidc.css @@ -0,0 +1,79 @@ +dl.api_key_permissions { + margin-top: 1em; + display: grid; + grid-template-columns: 1fr 2fr; +} + +dl.api_key_permissions dt { + font-weight: bold; + float: inherit; +} + +dl.oidc_access_policy { + display: grid; + column-gap: 1rem; + grid-template-columns: fit-content(6rem) auto; +} + +dl.oidc_access_policy > dd > * + * { + border-top-width: 2px; + border-top-style: solid; + border-top-color: #e2e8f0; + margin-top: .125rem; + padding-top: .125rem; +} + +dl.provider_attributes { + column-gap: 1rem; + align-items: baseline; + row-gap: 1rem; +} + +@media (max-width: 420px){ + dl.provider_attributes { + display: flex; + flex-direction: column; + } +} +@media (min-width: 421px){ + dl.provider_attributes { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +dl.full-width { + width: 100%; + overflow-wrap: break-word; + word-break: break-word; + +} + +dl.provider_attributes dt.text-right { + text-align: right; +} + +dl.provider_attributes dt.text-left { + text-align: left; +} + +dl.provider_attributes dd ul.tag-list { + display: flex; + flex-direction: row; + list-style: none; + justify-content: start; + column-gap: 1rem; + flex-wrap: wrap; + align-items: baseline; + margin: 0; +} + +dl.provider_attributes dd ul.tag-list li { + padding: 0.5rem 1rem; + background-color: #e2e8f0; + flex-shrink: 1; + border-radius: 9999px; +} +dl.provider_attributes dd ul.tag-list li:before { + height: 0; +} diff --git a/app/assets/stylesheets/modules/org.css b/app/assets/stylesheets/modules/org.css index ac6aabf226d..7d153f37537 100644 --- a/app/assets/stylesheets/modules/org.css +++ b/app/assets/stylesheets/modules/org.css @@ -1,19 +1,25 @@ /* Flash Intercoms */ .flash { - border-bottom: 1px solid #e9573f; - background-color: white; } + border-bottom: 1px solid #dcd3b1; + background-color: white; +} .flash-wrap { - padding-top: 5px; - padding-bottom: 5px; - background-image: linear-gradient(to right, rgba(233, 87, 63, 0.3), #e9573f); } + padding: 10px 0; + background-color: #fff6d2; +} .flash-wrap span { font-size: 13px; - font-style: italic; - color: #141c22; } + color: #141c22; +} +.flash a { + color: #141c22; + text-decoration: underline; + font-weight: bold; +} /* Announcement Styles */ #announcement { @@ -145,6 +151,11 @@ .profile__header__name { padding-top: 16px; } } +.profile__header__username { + overflow-x: auto; } +@media (min-width: 780px) { + .profile__header__username { + padding-top: 4px; } } @media (max-width: 779px) { .profile__header__avatar { @@ -155,7 +166,8 @@ height: 150px; width: 150px; float: left; - border-radius: 50%; } } + border-radius: 50%; + margin-bottom: 21px; } } .profile__header__attribute { padding-top: 10px; @@ -166,10 +178,11 @@ top: 1px; } .profile__header__icon { - height: 28px; + height: 14px; float: left; - margin-top: 3px; - margin-left: -5px; } + margin-top: 12px; + margin-left: 1px; + padding-right: 7px; } @media (max-width: 599px) { .profile__downloads-wrap { @@ -254,3 +267,20 @@ float: right; } .about__assets__download:focus:before, .about__assets__download:hover:before { animation: arrow .75s infinite; } + +/* Banner */ + +#banner { + background-color: #141c22; + text-align: center; + padding: 12px 0; + font-weight: 500; + font-size: 16px; + color: white; +} + +#banner a { + color: #e9573f; + text-decoration: underline; + font-weight: bold; +} diff --git a/app/assets/stylesheets/modules/owners.css b/app/assets/stylesheets/modules/owners.css index 56f9003b7a4..31b69225c77 100644 --- a/app/assets/stylesheets/modules/owners.css +++ b/app/assets/stylesheets/modules/owners.css @@ -1,6 +1,9 @@ .owners__row { background: #f6f6f6; } +.owners__row__invalid { + color: #9A9A9A; +} .owners__row:nth-of-type(odd) { background: #e9e9e9; } @@ -27,6 +30,13 @@ span.owners__icon img { padding-right: 2px; } +.owners__cell[data-title="MFA"] img { + margin: auto 0; + height: 23px; + width: 23px; + border-radius: 50%; +} + @media screen and (max-width: 640px) { .owners__tbody { display: block; @@ -70,3 +80,17 @@ table tr:first-child th:last-child { .t-body ul.scopes__list { margin: 3px 0px; } + +.ownership__details { + display: inline-block; +} + +.ownership__header { + border-bottom-style: solid; + border-color: #c1c4ca; + border-bottom-width: 5px; + padding-bottom: 20px; + padding-top: 20px; + text-transform: uppercase; + color: #e9573f; +} diff --git a/app/assets/stylesheets/modules/search.css b/app/assets/stylesheets/modules/search.css index 3c17109d737..c009b1b9347 100644 --- a/app/assets/stylesheets/modules/search.css +++ b/app/assets/stylesheets/modules/search.css @@ -96,7 +96,6 @@ color: #e9573f; } .home__search { - margin-bottom: 5px; padding-right: 48px; box-shadow: 0 0 0px 5px rgba(20, 28, 34, .1); box-sizing: border-box; @@ -136,11 +135,11 @@ color: white; } -dl { +dl.search-fields { margin: 6% 2%; } -dt { +dl.search-fields dt { float: left; padding: 11px 0px; color: #585858; @@ -148,11 +147,15 @@ dt { @media (min-width: 520px) { - dd { + dl.search-fields dd { margin-left: 25%; } } -dd input { +dl.search-fields dd input { max-width: none !important;; } + +.home__search-wrap center { + margin-top: 5px; +} diff --git a/app/assets/stylesheets/modules/shared.css b/app/assets/stylesheets/modules/shared.css index 78d5fa7846b..499adb14719 100644 --- a/app/assets/stylesheets/modules/shared.css +++ b/app/assets/stylesheets/modules/shared.css @@ -103,6 +103,16 @@ a.page__heading { margin-top: 3px; } +@media (min-width: 930px) { + .page__subheading--block { + font-size: 23px; + } + + h1.page__heading--small { + font-size: 40px; + } +} + .page__heading__info { font-weight: 200; font-size: 16px; @@ -167,17 +177,31 @@ a.page__heading { margin-top: 15px; } /* style used to be applied on iframe of github_buttons */ -span#github-btn { +span.github-btn { display: block; margin-bottom: 15px; margin-top: 5px; } +.recovery-code-list { + border: none; + padding: 10px 20px; + font-size: 1.2em; + font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + resize: none; + background: none; +} + +.recovery-code-list:focus { + background: none; + outline: none; +} + .recovery-code-list__item { font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } -.t-link--arrow { +.t-link--arrow, .t-link--bold { font-weight: bold; } @@ -193,3 +217,70 @@ span#github-btn { .t-link--arrow:hover:after { box-shadow: 3px -3px 0 0 rgba(233, 87, 63, 0.7) inset; } + +.t-body strong.recovery__bold { + font-weight: 800; +} + +.adoption__tag { + display: inline-block; + font-size: 0.8em; + font-weight: 800; + color: #e9573f; + border: 1px solid #e9573f; + border-radius: 8px; + padding: 0.25em 1em; + margin-bottom: 0.9em; + vertical-align: 0.3em; + transition: color 0.25s ease-in, + background-color 0.25s ease-in, + transform 0.3s ease-out; +} + +.adoption__tag:hover{ + color: white; + background-color: #e9573f; +} + +.adoption__heading { + color: #e9573f; +} + +.t-body h2.adoption__heading--no-padding { + padding-top: 0px; +} + +.adoption__blog__link { + color: #e9573f; + text-decoration: underline; + font-size: 0.6em; + font-weight: bold; +} + +.adoption__rdoc__link { + text-decoration: underline; + font-weight: bold; +} + +.tooltip__text { + color: #e9573f; + vertical-align: text-bottom; + cursor: pointer; + position: relative; + display: inline-block; +} + +.tooltip__text:hover::after { + position: absolute; + content: attr(data-tooltip); + bottom: 2.1em; + right: -1em; + background-color: #333; + color: white; + padding: .25em .5em; + font-size: .8em; +} + +.tooltip__text--reduced-font-size { + font-size: 0.7em; +} diff --git a/app/assets/stylesheets/modules/status-icon.css b/app/assets/stylesheets/modules/status-icon.css new file mode 100644 index 00000000000..31c953ac9c8 --- /dev/null +++ b/app/assets/stylesheets/modules/status-icon.css @@ -0,0 +1,50 @@ +.status-icon { + width: 100px; + display: block; + margin: 40px auto 0; } + +.status-icon .path { + stroke-dasharray: 1000; + stroke-dashoffset: 0; } + +.status-icon .path.circle { + animation: dash .9s ease-in-out; } + +.status-icon .path.line { + stroke-dashoffset: 1000; + animation: dash .9s .35s ease-in-out forwards; } + +.status-icon .path.check { + stroke-dashoffset: -100; + animation: dash-check .9s .35s ease-in-out forwards; } + +.status { + text-align: center; + margin: 20px 0 60px; + font-size: 1.25rem; } + +.status p { + margin-bottom: 1rem; +} + +.status.error { + color: #D06079; } + +.status.success { + color: #73AF55; } + +@keyframes dash { + 0% { + stroke-dashoffset: 1000; } + + 100% { + stroke-dashoffset: 0; + } } + +@keyframes dash-check { + 0% { + stroke-dashoffset: -100; } + + 100% { + stroke-dashoffset: 900; + } } diff --git a/app/assets/stylesheets/suggest-list.css b/app/assets/stylesheets/suggest-list.css new file mode 100644 index 00000000000..00fb484bec6 --- /dev/null +++ b/app/assets/stylesheets/suggest-list.css @@ -0,0 +1,25 @@ +.suggest-list { + position: absolute; + z-index: 1000; + width: 100%; + padding: 0; + list-style: none; + color: #ffffff; + background-color: rgba(20, 28, 34, 0.8); +} + +.suggest-list > li{ + margin-bottom: 2px; + padding: 4px; +} +.suggest-list > .selected{ + background-color: rgba(233, 87, 63, 0.8); +} + +.menu-item:hover { + cursor: pointer; +} + +.mobile-nav-is-expanded .suggest-list { + width: calc(100% - 24px); +} diff --git a/app/assets/stylesheets/tailwind.deprecated.css b/app/assets/stylesheets/tailwind.deprecated.css new file mode 100644 index 00000000000..cbb0ee2e997 --- /dev/null +++ b/app/assets/stylesheets/tailwind.deprecated.css @@ -0,0 +1,6 @@ +/* + * This is the old tailwind build that used `tw-` prefixes. + * We no longer compute this build and the tailwind config for this is gone. + * It's only here until the prefixed `tw-` classes are replaced by the new design. + */ +.tw-mx-auto{margin-left:auto;margin-right:auto}.tw-my-4{margin-bottom:1rem;margin-top:1rem}.\!tw-mb-0{margin-bottom:0!important}.tw-mt-2{margin-top:.5rem}.tw-mt-4{margin-top:1rem}.tw-flex{display:flex}.tw-flex-col{flex-direction:column}.tw-items-baseline{align-items:baseline}.tw-gap-2{gap:.5rem}.tw-gap-4{gap:1rem}.tw-space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.tw-space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.tw-divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-bottom-width:calc(1px*var(--tw-divide-y-reverse));border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)))}.tw-break-all{word-break:break-all}.tw-border{border-width:1px}.tw-border-solid{border-style:solid}.tw-border-red-500{--tw-border-opacity:1;border-color:rgb(239 68 68/var(--tw-border-opacity))}.\!tw-bg-white{--tw-bg-opacity:1!important;background-color:rgb(255 255 255/var(--tw-bg-opacity))!important}.tw-p-5{padding:1.25rem}.\!tw-py-0{padding-bottom:0!important;padding-top:0!important}.\!tw-text-start{text-align:start!important}@media (min-width:640px){.sm\:tw-flex{display:flex}.sm\:tw-grid{display:grid}.sm\:tw-grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:tw-flex-row{flex-direction:row}.sm\:tw-items-baseline{align-items:baseline}} diff --git a/app/assets/stylesheets/type.css b/app/assets/stylesheets/type.css index d2632646b83..ecba22f18c1 100644 --- a/app/assets/stylesheets/type.css +++ b/app/assets/stylesheets/type.css @@ -7,6 +7,25 @@ .t-display { font-size: 60px; } } +.t-display-full-name { + font-weight: 600; + white-space: nowrap;} +@media (max-width: 929px) { + .t-display-full-name { + font-size: 30px; } } +@media (min-width: 930px) { + .t-display-full-name { + font-size: 35px; } } + +.t-display-username { + font-weight: 200; } +@media (max-width: 929px) { + .t-display-username { + font-size: 20px; } } +@media (min-width: 930px) { + .t-display-username { + font-size: 25px; } } + .t-text { font-weight: 300; font-size: 18px; @@ -46,7 +65,7 @@ label.t-hidden { text-transform: uppercase; color: #e9573f; } .t-list__heading:not(:first-child) { - margin-top: 25px; } + margin-top: 30px; } .t-list__items, .t-bulleted-items, .t-bulleted-item { margin-top: 18px; } @@ -106,6 +125,9 @@ a.t-list__item { font-style: normal; content: "→"; } +.t-underline { + text-decoration: underline; } + .t-body p, .t-body ol li, .t-body ul li { font-weight: 300; font-size: 18px; @@ -156,9 +178,12 @@ a.t-list__item { overflow-x: scroll; line-height: 1.33; word-break: normal; } + .t-body pre code.multiline { + word-spacing: inherit; } .t-body code { font-weight: bold; - font-family: "courier", monospace; } + font-family: "courier", monospace; + word-spacing: -0.3em; } .t-body ul, .t-body ol { margin-top: 18px; } .t-body ul li, .t-body ol li { diff --git a/app/avo/actions/add_owner.rb b/app/avo/actions/add_owner.rb new file mode 100644 index 00000000000..bd98d1b8993 --- /dev/null +++ b/app/avo/actions/add_owner.rb @@ -0,0 +1,40 @@ +class AddOwner < BaseAction + self.name = "Add owner" + self.visible = lambda { + current_user.team_member?("rubygems-org") && view == :show + } + + field :owner, as: :select_record, searchable: true, name: "New owner", use_resource: UserResource + + self.message = lambda { + "Are you sure you would like to add an owner to #{record.name}?" + } + + self.confirm_button_label = "Add owner" + + class ActionHandler < ActionHandler + set_callback :handle, :before do + @owner = fields[:owner] + error "Must specify a valid user to add as owner" if @owner.blank? + end + + set_callback :handle, :before do + error "Cannot add #{@owner.name} as an owner since they are unconfirmed" if @owner.unconfirmed? + end + + def do_handle_model(rubygem) + @rubygem = rubygem + super + end + + set_callback :handle_model, :before do + error "Cannot add #{@owner.name} as an owner since they are already an owner of #{@rubygem.name}" if @owner.rubygems.include?(@rubygem) + end + + def handle_model(rubygem) + authorizer = User.security_user + rubygem.ownerships.create!(user: @owner, authorizer: authorizer, confirmed_at: Time.current) + succeed "Added #{@owner.name} to #{@rubygem.name}" + end + end +end diff --git a/app/avo/actions/base_action.rb b/app/avo/actions/base_action.rb new file mode 100644 index 00000000000..4e985aef5b2 --- /dev/null +++ b/app/avo/actions/base_action.rb @@ -0,0 +1,125 @@ +class BaseAction < Avo::BaseAction + include SemanticLogger::Loggable + + field :comment, as: :textarea, required: true, + help: "A comment explaining why this action was taken.
    Will be saved in the audit log.
    Must be more than 10 characters." + + def self.inherited(base) + super + base.items_holder = Avo::ItemsHolder.new + base.items_holder.instance_variable_get(:@items).replace items_holder.instance_variable_get(:@items).deep_dup + base.items_holder.invalid_fields.replace items_holder.invalid_fields.deep_dup + end + + class ActionHandler + include Auditable + include SemanticLogger::Loggable + + include ActiveSupport::Callbacks + define_callbacks :handle, terminator: lambda { |target, result_lambda| + result_lambda.call + target.errored? + } + define_callbacks :handle_model, terminator: lambda { |target, result_lambda| + result_lambda.call + target.errored? + } + define_callbacks :handle_standalone, terminator: lambda { |target, result_lambda| + result_lambda.call + target.errored? + } + + def initialize( # rubocop:disable Metrics/ParameterLists + fields:, + current_user:, + arguments:, + resource:, + action:, + models: nil + ) + @models = models + @fields = fields + @current_user = current_user + @arguments = arguments + @resource = resource + + @action = action + end + + attr_reader :models, :fields, :current_user, :arguments, :resource + + delegate :error, :avo, :keep_modal_open, :redirect_to, :inform, :action_name, :succeed, :logger, + to: :@action + + set_callback :handle, :before do + error "Must supply a sufficiently detailed comment" unless fields[:comment].presence&.then { _1.length >= 10 } + end + + set_callback :handle, :around, lambda { |_, block| + begin + block.call + rescue StandardError => e + Rails.error.report(e, handled: true) + error e.message.truncate(300) + end + } + + def do_handle + run_callbacks :handle do + handle + end + ensure + keep_modal_open if errored? + end + + def do_handle_model(model) + run_callbacks :handle_model do + handle_model(model) + end + end + + def errored? + @action.response[:messages].any? { _1[:type] == :error } + end + + def do_handle_standalone + _, audit = in_audited_transaction( + auditable: :return, + admin_github_user: current_user, + action: action_name, + fields:, + arguments:, + models: + ) do + run_callbacks :handle_standalone do + handle_standalone + end + end + redirect_to avo.resources_audit_path(audit) + end + + def handle + return do_handle_standalone if models.nil? + models.each do |model| + _, audit = in_audited_transaction( + auditable: model, + admin_github_user: current_user, + action: action_name, + fields:, + arguments:, + models: + ) do + do_handle_model(model) + end + redirect_to avo.resources_audit_path(audit) + end + end + end + + def handle(**args) + "#{self.class}::ActionHandler" + .constantize + .new(**args, arguments:, action: self) + .do_handle + end +end diff --git a/app/avo/actions/block_user.rb b/app/avo/actions/block_user.rb new file mode 100644 index 00000000000..d73eb3fb7f8 --- /dev/null +++ b/app/avo/actions/block_user.rb @@ -0,0 +1,18 @@ +class BlockUser < BaseAction + self.name = "Block User" + self.visible = lambda { + current_user.team_member?("rubygems-org") && view == :show + } + + self.message = lambda { + "Are you sure you would like to block user #{record.handle} with #{record.email}?" + } + + self.confirm_button_label = "Block User" + + class ActionHandler < ActionHandler + def handle_model(user) + user.block! + end + end +end diff --git a/app/avo/actions/change_user_email.rb b/app/avo/actions/change_user_email.rb new file mode 100644 index 00000000000..949a0f82feb --- /dev/null +++ b/app/avo/actions/change_user_email.rb @@ -0,0 +1,22 @@ +class ChangeUserEmail < BaseAction + field :from_email, name: "Email", as: :text, required: true + + self.name = "Change User Email" + self.visible = lambda { + current_user.team_member?("rubygems-org") && view == :show + } + + self.confirm_button_label = "Change User Email" + + class ActionHandler < ActionHandler + def handle_model(user) + user.email = fields["from_email"] + user.email_confirmed = false + user.generate_confirmation_token + + return unless user.save! + + Mailer.email_confirmation(user).deliver_later + end + end +end diff --git a/app/avo/actions/create_user.rb b/app/avo/actions/create_user.rb new file mode 100644 index 00000000000..8abd11d3969 --- /dev/null +++ b/app/avo/actions/create_user.rb @@ -0,0 +1,26 @@ +class CreateUser < BaseAction + field :email, name: "Email", as: :text, required: true + + self.name = "Create User" + self.visible = lambda { + current_user.team_member?("rubygems-org") && view == :index && !Rails.env.production? + } + self.standalone = true + + self.confirm_button_label = "Create User" + + class ActionHandler < ActionHandler + def handle_standalone + user = User.new( + email: fields["email"], + password: SecureRandom.hex(16), + email_confirmed: true + ) + user.generate_confirmation_token + user.save! + + ::PasswordMailer.change_password(user).deliver_later + user + end + end +end diff --git a/app/avo/actions/delete_webhook.rb b/app/avo/actions/delete_webhook.rb new file mode 100644 index 00000000000..4a696fbc5c8 --- /dev/null +++ b/app/avo/actions/delete_webhook.rb @@ -0,0 +1,19 @@ +class DeleteWebhook < BaseAction + self.name = "Delete Webhook" + self.visible = lambda { + current_user.team_member?("rubygems-org") && view == :show + } + + self.message = lambda { + "Are you sure you would like to delete the webhook #{record.url} for #{record.what} (owned by #{record.user.name})?" + } + + self.confirm_button_label = "Delete Webhook" + + class ActionHandler < ActionHandler + def handle_model(webhook) + webhook.destroy! + WebHooksMailer.webhook_deleted(webhook.user_id, webhook.rubygem_id, webhook.url, webhook.failure_count).deliver_later + end + end +end diff --git a/app/avo/actions/refresh_oidc_provider.rb b/app/avo/actions/refresh_oidc_provider.rb new file mode 100644 index 00000000000..c53e53d766f --- /dev/null +++ b/app/avo/actions/refresh_oidc_provider.rb @@ -0,0 +1,18 @@ +class RefreshOIDCProvider < BaseAction + self.name = "Refresh OIDC Provider" + self.visible = lambda { + current_user.team_member?("rubygems-org") && view == :show + } + + self.message = lambda { + "Are you sure you would like to refresh #{record.issuer}?" + } + + self.confirm_button_label = "Refresh" + + class ActionHandler < ActionHandler + def handle_model(provider) + RefreshOIDCProviderJob.perform_now(provider:) + end + end +end diff --git a/app/avo/actions/release_reserved_namespace.rb b/app/avo/actions/release_reserved_namespace.rb new file mode 100644 index 00000000000..1cbbe8b793c --- /dev/null +++ b/app/avo/actions/release_reserved_namespace.rb @@ -0,0 +1,18 @@ +class ReleaseReservedNamespace < BaseAction + self.name = "Release reserved namespace" + self.visible = lambda { + current_user.team_member?("rubygems-org") && view == :show + } + + self.message = lambda { + "Are you sure you would like to Release #{record.name} namespace?" + } + + self.confirm_button_label = "Release namespace" + + class ActionHandler < ActionHandler + def handle_model(rubygem) + rubygem.release_reserved_namespace! + end + end +end diff --git a/app/avo/actions/reset_api_key.rb b/app/avo/actions/reset_api_key.rb new file mode 100644 index 00000000000..9f5240c4958 --- /dev/null +++ b/app/avo/actions/reset_api_key.rb @@ -0,0 +1,25 @@ +class ResetApiKey < BaseAction + self.name = "Reset Api Key" + self.visible = lambda { + current_user.team_member?("rubygems-org") && view == :show + } + self.message = lambda { + "Are you sure you would like to reset api key for #{record.handle} #{record.email}?" + } + self.confirm_button_label = "Reset Api Key" + + field :template, as: :select, + options: { + "Public Gem": :public_gem_reset_api_key, + Honeycomb: :honeycomb_reset_api_key + }, + help: "Select mailer template" + + class ActionHandler < ActionHandler + def handle_model(user) + user.reset_api_key! + + Mailer.reset_api_key(user, fields["template"]).deliver_later + end + end +end diff --git a/app/avo/actions/reset_user_2fa.rb b/app/avo/actions/reset_user_2fa.rb new file mode 100644 index 00000000000..03e19a1d518 --- /dev/null +++ b/app/avo/actions/reset_user_2fa.rb @@ -0,0 +1,20 @@ +class ResetUser2fa < BaseAction + self.name = "Reset User 2FA" + self.visible = lambda { + current_user.team_member?("rubygems-org") && view == :show + } + + self.message = lambda { + "Are you sure you would like to disable MFA and reset the password for #{record.handle} #{record.email}?" + } + + self.confirm_button_label = "Reset MFA" + + class ActionHandler < ActionHandler + def handle_model(user) + user.disable_totp! + user.password = SecureRandom.hex(20).encode("UTF-8") + user.save! + end + end +end diff --git a/app/avo/actions/restore_version.rb b/app/avo/actions/restore_version.rb new file mode 100644 index 00000000000..2a5bf7f250f --- /dev/null +++ b/app/avo/actions/restore_version.rb @@ -0,0 +1,18 @@ +class RestoreVersion < BaseAction + self.name = "Restore version" + self.visible = lambda { + current_user.team_member?("rubygems-org") && + view == :show && + resource.model.deletion.present? + } + self.message = lambda { + "Are you sure you would like to restore #{record.slug} with " + } + self.confirm_button_label = "Restore version" + + class ActionHandler < ActionHandler + def handle_model(version) + version.deletion&.restore! + end + end +end diff --git a/app/avo/actions/unblock_user.rb b/app/avo/actions/unblock_user.rb new file mode 100644 index 00000000000..69643f42433 --- /dev/null +++ b/app/avo/actions/unblock_user.rb @@ -0,0 +1,18 @@ +class UnblockUser < BaseAction + self.name = "Unblock User" + self.visible = lambda { + current_user.team_member?("rubygems-org") && view == :show && record.blocked? + } + + self.message = lambda { + "Are you sure you would like to unblock user #{record.handle} with #{record.blocked_email}?" + } + + self.confirm_button_label = "Unblock User" + + class ActionHandler < ActionHandler + def handle_model(user) + user.unblock! + end + end +end diff --git a/app/avo/actions/upload_info_file.rb b/app/avo/actions/upload_info_file.rb new file mode 100644 index 00000000000..38740af0a4f --- /dev/null +++ b/app/avo/actions/upload_info_file.rb @@ -0,0 +1,15 @@ +class UploadInfoFile < BaseAction + self.name = "Upload Info File" + self.visible = lambda { + current_user.team_member?("rubygems-org") && view == :show + } + self.confirm_button_label = "Upload" + + class ActionHandler < ActionHandler + def handle_model(rubygem) + UploadInfoFileJob.perform_later(rubygem_name: rubygem.name) + + succeed("Upload job scheduled") + end + end +end diff --git a/app/avo/actions/upload_names_file.rb b/app/avo/actions/upload_names_file.rb new file mode 100644 index 00000000000..1ab53b4ce95 --- /dev/null +++ b/app/avo/actions/upload_names_file.rb @@ -0,0 +1,18 @@ +class UploadNamesFile < BaseAction + self.name = "Upload Names File" + self.visible = lambda { + current_user.team_member?("rubygems-org") && view == :index + } + self.standalone = true + self.confirm_button_label = "Upload" + + class ActionHandler < ActionHandler + def handle_standalone + UploadNamesFileJob.perform_later + + succeed("Upload job scheduled") + + Version.last + end + end +end diff --git a/app/avo/actions/upload_versions_file.rb b/app/avo/actions/upload_versions_file.rb new file mode 100644 index 00000000000..ee5f700a02a --- /dev/null +++ b/app/avo/actions/upload_versions_file.rb @@ -0,0 +1,18 @@ +class UploadVersionsFile < BaseAction + self.name = "Upload Versions File" + self.visible = lambda { + current_user.team_member?("rubygems-org") && view == :index + } + self.standalone = true + self.confirm_button_label = "Upload" + + class ActionHandler < ActionHandler + def handle_standalone + UploadVersionsFileJob.perform_later + + succeed("Upload job scheduled") + + Version.last + end + end +end diff --git a/app/avo/actions/version_after_write.rb b/app/avo/actions/version_after_write.rb new file mode 100644 index 00000000000..94490970847 --- /dev/null +++ b/app/avo/actions/version_after_write.rb @@ -0,0 +1,20 @@ +class VersionAfterWrite < BaseAction + self.name = "Run version post-write job" + self.visible = lambda { + current_user.team_member?("rubygems-org") && + view == :show && + resource.model.deletion.blank? + } + + self.message = lambda { + "Are you sure you would like to run the after-write job for #{record.full_name}? The version is #{'not ' unless record.indexed?} indexed." + } + + self.confirm_button_label = "Run Job" + + class ActionHandler < ActionHandler + def handle_model(version) + AfterVersionWriteJob.new(version: version).perform(version: version) + end + end +end diff --git a/app/avo/actions/yank_rubygem.rb b/app/avo/actions/yank_rubygem.rb new file mode 100644 index 00000000000..21c9771ef37 --- /dev/null +++ b/app/avo/actions/yank_rubygem.rb @@ -0,0 +1,31 @@ +class YankRubygem < BaseAction + OPTION_ALL = "All".freeze + + field :version, as: :select, + options: lambda { |model:, resource:, view:, field:| # rubocop:disable Lint/UnusedBlockArgument + [OPTION_ALL] + model.versions.indexed.pluck(:number, :id) + }, + help: "Select Version which needs to be yanked." + + self.name = "Yank Rubygem" + self.visible = lambda { + current_user.team_member?("rubygems-org") && + view == :show && + resource.model.versions.indexed.present? + } + + self.message = lambda { + "Are you sure you would like to yank gem #{record.name}?" + } + + self.confirm_button_label = "Yank Rubygem" + + class ActionHandler < ActionHandler + def handle_model(rubygem) + version_id = fields["version"] + version_id_to_yank = version_id if version_id != OPTION_ALL + + rubygem.yank_versions!(version_id: version_id_to_yank, force: true) + end + end +end diff --git a/app/avo/actions/yank_rubygems_for_user.rb b/app/avo/actions/yank_rubygems_for_user.rb new file mode 100644 index 00000000000..d6f5e00e334 --- /dev/null +++ b/app/avo/actions/yank_rubygems_for_user.rb @@ -0,0 +1,22 @@ +class YankRubygemsForUser < BaseAction + self.name = "Yank all Rubygems" + self.visible = lambda { + current_user.team_member?("rubygems-org") && + view == :show && + resource.model.rubygems.present? + } + + self.message = lambda { + "Are you sure you would like to yank all rubygems for user #{record.handle} with #{record.email}?" + } + + self.confirm_button_label = "Yank all Rubygems" + + class ActionHandler < ActionHandler + def handle_model(user) + user.rubygems.find_each do |rubygem| + rubygem.yank_versions!(force: true) + end + end + end +end diff --git a/app/avo/actions/yank_user.rb b/app/avo/actions/yank_user.rb new file mode 100644 index 00000000000..1f4f34acdf8 --- /dev/null +++ b/app/avo/actions/yank_user.rb @@ -0,0 +1,19 @@ +class YankUser < BaseAction + self.name = "Yank User" + self.visible = lambda { + current_user.team_member?("rubygems-org") && view == :show + } + self.message = lambda { + "Are you sure you would like to yank user #{record.handle} #{record.email}? It will block user and yank all associated rubygems" + } + self.confirm_button_label = "Yank User" + + class ActionHandler < ActionHandler + def handle_model(user) + user.rubygems.find_each do |rubygem| + rubygem.yank_versions!(force: true) + end + user.block! + end + end +end diff --git a/app/avo/cards/dashboard_welcome_card.rb b/app/avo/cards/dashboard_welcome_card.rb new file mode 100644 index 00000000000..8cbadb73c47 --- /dev/null +++ b/app/avo/cards/dashboard_welcome_card.rb @@ -0,0 +1,7 @@ +class DashboardWelcomeCard < Avo::Dashboards::PartialCard + self.id = "dashboard_welcome_card" + self.label = "Welcome to the RubyGems.org admin dashboard!" + self.partial = "avo/cards/dashboard_welcome_card" + self.display_header = true + self.cols = 6 +end diff --git a/app/avo/cards/pushes_chart.rb b/app/avo/cards/pushes_chart.rb new file mode 100644 index 00000000000..5d45743df0b --- /dev/null +++ b/app/avo/cards/pushes_chart.rb @@ -0,0 +1,11 @@ +class PushesChart < Avo::Dashboards::ChartkickCard + self.id = "pushes_chart" + self.label = "Pushes by day" + self.chart_type = :line_chart + self.cols = 6 + self.rows = 2 + + def query + result Version.group_by_period(:day, :created_at, last: 30).count + end +end diff --git a/app/avo/cards/rubygems_metric.rb b/app/avo/cards/rubygems_metric.rb new file mode 100644 index 00000000000..03f9dfc57a6 --- /dev/null +++ b/app/avo/cards/rubygems_metric.rb @@ -0,0 +1,43 @@ +class RubygemsMetric < Avo::Dashboards::MetricCard + self.id = "rubygems_metric" + self.label = "RubyGems " + self.cols = 2 + self.initial_range = "ALL" + self.ranges = { + "7 days": 7, + "30 days": 30, + "60 days": 60, + "365 days": 365, + Today: "TODAY", + "Month to date": "MTD", + "Quarter to date": "QTD", + "Year to date": "YTD", + All: "ALL" + } + + def query + from = Time.zone.today.midnight - 1.week + to = Time.zone.now + + if range.present? + if range.to_s == range.to_i.to_s + from = to - range.to_i.days + else + case range + when "TODAY" + from = to.beginning_of_day + when "MTD" + from = to.beginning_of_month + when "QTD" + from = to.beginning_of_quarter + when "YTD" + from = to.beginning_of_year + when "ALL" + from = Time.zone.at(0) + end + end + end + + result Rubygem.where(created_at: from..to).count + end +end diff --git a/app/avo/cards/users_metric.rb b/app/avo/cards/users_metric.rb new file mode 100644 index 00000000000..18033d49463 --- /dev/null +++ b/app/avo/cards/users_metric.rb @@ -0,0 +1,9 @@ +class UsersMetric < Avo::Dashboards::MetricCard + self.id = "users_metric" + self.label = "Total users" + self.cols = 2 + + def query + result User.count + end +end diff --git a/app/avo/cards/versions_metric.rb b/app/avo/cards/versions_metric.rb new file mode 100644 index 00000000000..fefa912a30f --- /dev/null +++ b/app/avo/cards/versions_metric.rb @@ -0,0 +1,44 @@ +class VersionsMetric < Avo::Dashboards::MetricCard + self.id = "versions_metric" + self.label = "Versions pushed" + + self.cols = 2 + self.initial_range = "ALL" + self.ranges = { + "7 days": 7, + "30 days": 30, + "60 days": 60, + "365 days": 365, + Today: "TODAY", + "Month to date": "MTD", + "Quarter to date": "QTD", + "Year to date": "YTD", + All: "ALL" + } + + def query + from = Time.zone.today.midnight - 1.week + to = Time.zone.now + + if range.present? + if range.to_s == range.to_i.to_s + from = to - range.to_i.days + else + case range + when "TODAY" + from = to.beginning_of_day + when "MTD" + from = to.beginning_of_month + when "QTD" + from = to.beginning_of_quarter + when "YTD" + from = to.beginning_of_year + when "ALL" + from = Time.zone.at(0) + end + end + end + + result Version.where(created_at: from..to).count + end +end diff --git a/app/avo/concerns/auditable.rb b/app/avo/concerns/auditable.rb new file mode 100644 index 00000000000..79cd0667a75 --- /dev/null +++ b/app/avo/concerns/auditable.rb @@ -0,0 +1,62 @@ +module Auditable + extend ActiveSupport::Concern + + included do + include SemanticLogger::Loggable + + def merge_changes!(changes, changes_to_save) + changes.merge!(changes_to_save) do |_key, (old, _), (_, new)| + [old, new] + end + end + + def in_audited_transaction(auditable:, admin_github_user:, action:, fields:, arguments:, models:, &blk) # rubocop:disable Metrics Naming/BlockForwarding + logger.debug { "Auditing changes to #{auditable}: #{fields}" } + + User.transaction do + changed_records = {} + value = ActiveSupport::Notifications.subscribed(proc do |_name, _started, _finished, _unique_id, data| + # need to rehash because ActiveRecord::Core#hash changes when a record is saved + # for the first time + changed_records.rehash + + records = data[:connection].transaction_manager.current_transaction.records || [] + records.uniq(&:__id__).each do |record| + merge_changes!((changed_records[record] ||= {}), record.attributes.transform_values { [nil, _1] }) if record.new_record? + merge_changes!((changed_records[record] ||= {}), record.changes_to_save) + end + end, "sql.active_record", &blk) + + case auditable + when :return + auditable = value + when Proc + auditable = auditable.call(changed_records:) + end + + audited_changed_records = changed_records.to_h do |record, changes| + key = record.to_global_id.uri + changes = merge_changes!(changes, record.attributes.slice("id").transform_values { [_1, _1] }) if changes.key?("id") + changes = merge_changes!(changes, record.attributes.compact.transform_values { [_1, nil] }) if record.destroyed? + + [key, { changes:, unchanged: record.attributes.except(*changes.keys) }] + end + + audit = Audit.create!( + admin_github_user:, + auditable:, + action:, + comment: fields.fetch(:comment), + audited_changes: { + records: audited_changed_records, + fields: fields.except(:comment), + arguments: arguments, + models: models&.map { _1.to_global_id.uri } + } + ) + + [value, audit] + end + end + end +end diff --git a/app/avo/dashboards/dashy.rb b/app/avo/dashboards/dashy.rb new file mode 100644 index 00000000000..3a5ef54376c --- /dev/null +++ b/app/avo/dashboards/dashy.rb @@ -0,0 +1,21 @@ +class Dashy < Avo::Dashboards::BaseDashboard + self.id = "dashy" + self.name = "Dashy" + self.grid_cols = 6 + self.visible = lambda { + current_user.team_member?("rubygems-org") + } + + # cards go here + card DashboardWelcomeCard + + divider label: "Metrics" + + card UsersMetric + card VersionsMetric + card RubygemsMetric + + divider label: "Charts" + + card PushesChart +end diff --git a/app/avo/fields/array_of_field.rb b/app/avo/fields/array_of_field.rb new file mode 100644 index 00000000000..7688f1662ed --- /dev/null +++ b/app/avo/fields/array_of_field.rb @@ -0,0 +1,38 @@ +class ArrayOfField < Avo::Fields::BaseField + def initialize(name, field:, field_options: {}, **args, &block) + super(name, **args, &nil) + + @make_field = lambda do |id:, index: nil, value: nil| + items_holder = Avo::ItemsHolder.new + items_holder.field(id, name: index&.to_s || self.name, as: field, required: -> { false }, value:, **field_options, &block) + items_holder.items.sole.hydrate(view:, resource:) + end + end + + def value(...) + value = super + Array.wrap(value) + end + + def template_member + @make_field[id: "#{id}[NEW_RECORD]"] + end + + def fill_field(model, key, value, params) + value = value.each_value.map do |v| + template_member.fill_field(NestedField::Holder.new, :item, v, params).item + end + super + end + + def members + value.each_with_index.map do |value, idx| + id = "#{self.id}[#{idx}]" + @make_field[id:, index: idx, value:] + end + end + + def to_permitted_param + @make_field[id:].to_permitted_param + end +end diff --git a/app/avo/fields/audited_changes_field.rb b/app/avo/fields/audited_changes_field.rb new file mode 100644 index 00000000000..4d4398abcb3 --- /dev/null +++ b/app/avo/fields/audited_changes_field.rb @@ -0,0 +1,2 @@ +class AuditedChangesField < Avo::Fields::BaseField +end diff --git a/app/avo/fields/event_additional_field.rb b/app/avo/fields/event_additional_field.rb new file mode 100644 index 00000000000..3e42dc93235 --- /dev/null +++ b/app/avo/fields/event_additional_field.rb @@ -0,0 +1,33 @@ +class EventAdditionalField < Avo::Fields::BaseField + def nested_field + return unless @model + additional_type = @model.additional_type + if additional_type.nil? + return JsonViewerField.new(id, **@args) + .hydrate(model:, resource:, action:, view:, panel_name:, user:) + end + + NestedField.new(id, **@args) do + additional_type.attribute_types.each do |attribute_name, type| + case type + when Types::GlobalId + field attribute_name.to_sym, as: :global_id, show_on: :index + when ActiveModel::Type::String + field attribute_name.to_sym, as: :text, show_on: :index + when ActiveModel::Type::Boolean + field attribute_name.to_sym, as: :boolean, show_on: :index + else + field attribute_name.to_sym, as: :json_viewer, hide_on: :index + end + end + end.hydrate(model:, resource:, action:, view:, panel_name:, user:) + end + + methods = %i[fill_field value update_using to_permitted_param component_for_view visible get_fields] + methods.each do |method| + define_method(method, &lambda do |*args, **kwargs| + nf = nested_field + nf ? nf.send(method, *args, **kwargs) : super(*args, **kwargs) + end) + end +end diff --git a/app/avo/fields/global_id_field.rb b/app/avo/fields/global_id_field.rb new file mode 100644 index 00000000000..0f72f5637af --- /dev/null +++ b/app/avo/fields/global_id_field.rb @@ -0,0 +1,15 @@ +class GlobalIdField < Avo::Fields::BelongsToField + include SemanticLogger::Loggable + + delegate(*%i[values_for_type custom?], to: :@nil) + + def value + super&.find + rescue ActiveRecord::RecordNotFound + nil + end + + def view_component_name = "BelongsToField" + + def is_polymorphic? = true # rubocop:disable Naming/PredicateName +end diff --git a/app/avo/fields/json_viewer_field.rb b/app/avo/fields/json_viewer_field.rb new file mode 100644 index 00000000000..df4d52e45d3 --- /dev/null +++ b/app/avo/fields/json_viewer_field.rb @@ -0,0 +1,9 @@ +class JsonViewerField < Avo::Fields::CodeField + def initialize(name, **args, &) + super(name, **args, language: :javascript, line_wrapping: true, &) + end + + def value(...) + super&.then { JSON.pretty_generate(_1.as_json) } + end +end diff --git a/app/avo/fields/nested_field.rb b/app/avo/fields/nested_field.rb new file mode 100644 index 00000000000..769216a026a --- /dev/null +++ b/app/avo/fields/nested_field.rb @@ -0,0 +1,32 @@ +class NestedField < Avo::Fields::BaseField + include Avo::Concerns::HasFields + + def initialize(name, stacked: true, **args, &block) + @items_holder = Avo::ItemsHolder.new + hide_on :index + super(name, stacked:, **args, &nil) + instance_exec(&block) if block + end + + def fields(**_kwargs) + @items_holder.instance_variable_get(:@items).grep Avo::Fields::BaseField + end + + delegate :field, to: :@items_holder + + def fill_field(model, key, value, params) + value = value.to_h.to_h do |k, v| + [k, get_field(k).fill_field(Holder.new, :item, v, params).item] + end + + super + end + + def to_permitted_param + { super => fields.map(&:to_permitted_param) } + end + + class Holder + attr_accessor :item + end +end diff --git a/app/avo/fields/select_record_field.rb b/app/avo/fields/select_record_field.rb new file mode 100644 index 00000000000..83da8985919 --- /dev/null +++ b/app/avo/fields/select_record_field.rb @@ -0,0 +1,10 @@ +class SelectRecordField < Avo::Fields::BelongsToField + def foreign_key + id + end + + def resolve_attribute(value) + return if value.blank? + target_resource.find_record value + end +end diff --git a/app/avo/filters/email_filter.rb b/app/avo/filters/email_filter.rb new file mode 100644 index 00000000000..2a3375c86c5 --- /dev/null +++ b/app/avo/filters/email_filter.rb @@ -0,0 +1,14 @@ +class EmailFilter < Avo::Filters::TextFilter + self.name = "Email filter" + self.button_label = "Filter by email" + + def apply(_request, query, value) + query.where(query.arel_table[email_column].matches_regexp(value, false)) + end + + private + + def email_column + arguments.fetch(:column, :email) + end +end diff --git a/app/avo/filters/scope_boolean_filter.rb b/app/avo/filters/scope_boolean_filter.rb new file mode 100644 index 00000000000..6addbbcb400 --- /dev/null +++ b/app/avo/filters/scope_boolean_filter.rb @@ -0,0 +1,22 @@ +class ScopeBooleanFilter < Avo::Filters::BooleanFilter + def name + arguments.fetch(:name) { self.class.to_s.demodulize.underscore.sub(/_filter$/, "").titleize } + end + + def apply(_request, query, values) + return query if default.each_key.all? { values[_1] } + + default.each_key.reduce(query.none) do |relation, scope| + next relation unless values[scope] + relation.or(query.send(scope)) + end + end + + def default + arguments[:default].stringify_keys + end + + def options + default.to_h { |k, _| [k, k.titleize] } + end +end diff --git a/app/avo/resources/admin_github_user_resource.rb b/app/avo/resources/admin_github_user_resource.rb new file mode 100644 index 00000000000..65a1e6c79e3 --- /dev/null +++ b/app/avo/resources/admin_github_user_resource.rb @@ -0,0 +1,31 @@ +class AdminGitHubUserResource < Avo::BaseResource + self.title = :login + self.includes = [] + self.model_class = ::Admin::GitHubUser + self.authorization_policy = ::Admin::GitHubUserPolicy + self.search_query = lambda { + scope.where("login LIKE ?", "%#{params[:q]}%") + } + + self.description = "GitHub users that have authenticated via the admin OAuth flow." + + field :id, as: :id + + field :is_admin, as: :boolean, readonly: true + field :login, as: :text, readonly: true, + as_html: true, + format_using: -> { link_to value, "https://github.com/#{value}" } + field :avatar_url, as: :external_image, name: "Avatar", readonly: true + field :github_id, as: :text, readonly: true + field :oauth_token, as: :text, visible: ->(resource:) { false } # rubocop:disable Lint/UnusedBlockArgument + + heading "Details" + + field :teams, as: :tags, readonly: true, format_using: -> { value.pluck(:slug) } + + field :info_data, + as: :code, readonly: true, language: :javascript, + format_using: -> { JSON.pretty_generate value } + + field :audits, as: :has_many +end diff --git a/app/avo/resources/api_key_resource.rb b/app/avo/resources/api_key_resource.rb new file mode 100644 index 00000000000..dfcec27ceb2 --- /dev/null +++ b/app/avo/resources/api_key_resource.rb @@ -0,0 +1,39 @@ +class ApiKeyResource < Avo::BaseResource + self.title = :name + self.includes = [] + + class ExpiredFilter < ScopeBooleanFilter; end + filter ExpiredFilter, arguments: { default: { expired: false, unexpired: true } } + + field :id, as: :id, hide_on: :index + + field :name, as: :text, link_to_resource: true + field :hashed_key, as: :text, visible: ->(_) { false } + field :user, as: :belongs_to, visible: ->(_) { false } + field :owner, as: :belongs_to, + polymorphic_as: :owner, + types: ["User", "OIDC::TrustedPublisher::GitHubAction"] + field :last_accessed_at, as: :date_time + field :soft_deleted_at, as: :date_time + field :soft_deleted_rubygem_name, as: :text + field :expires_at, as: :date_time + + field :scopes, as: :tags + + sidebar do + heading "Permissions" + + field :index_rubygems, as: :boolean + field :push_rubygem, as: :boolean + field :yank_rubygem, as: :boolean + field :add_owner, as: :boolean + field :remove_owner, as: :boolean + field :access_webhooks, as: :boolean + field :show_dashboard, as: :boolean + field :mfa, as: :boolean + end + + field :api_key_rubygem_scope, as: :has_one + field :ownership, as: :has_one + field :oidc_id_token, as: :has_one +end diff --git a/app/avo/resources/api_key_rubygem_scope_resource.rb b/app/avo/resources/api_key_rubygem_scope_resource.rb new file mode 100644 index 00000000000..9560edba16c --- /dev/null +++ b/app/avo/resources/api_key_rubygem_scope_resource.rb @@ -0,0 +1,9 @@ +class ApiKeyRubygemScopeResource < Avo::BaseResource + self.title = :cache_key + self.includes = [] + + field :id, as: :id + + field :api_key, as: :belongs_to + field :ownership, as: :belongs_to +end diff --git a/app/avo/resources/audit_resource.rb b/app/avo/resources/audit_resource.rb new file mode 100644 index 00000000000..548729de209 --- /dev/null +++ b/app/avo/resources/audit_resource.rb @@ -0,0 +1,36 @@ +class AuditResource < Avo::BaseResource + self.title = :id + self.includes = %i[ + admin_github_user + auditable + ] + + field :action, as: :text + + sidebar do + field :admin_github_user, as: :belongs_to + field :created_at, as: :date_time + field :comment, as: :text + + field :auditable, as: :belongs_to, + polymorphic_as: :auditable, + types: %w[User WebHook], + name: "Edited Record" + + heading "Action Details" + + field :audited_changes_arguments, as: :json_viewer, only_on: :show do |model| + model.audited_changes["arguments"] + end + field :audited_changes_fields, as: :json_viewer, only_on: :show do |model| + model.audited_changes["fields"] + end + field :audited_changes_models, as: :text, as_html: true, only_on: :show do + model.audited_changes["models"] + end + + field :id, as: :id + end + + field :audited_changes, as: :audited_changes, except_on: :index +end diff --git a/app/avo/resources/concerns/avo_auditable_resource.rb b/app/avo/resources/concerns/avo_auditable_resource.rb new file mode 100644 index 00000000000..5eb219fb67b --- /dev/null +++ b/app/avo/resources/concerns/avo_auditable_resource.rb @@ -0,0 +1,20 @@ +module Concerns::AvoAuditableResource + extend ActiveSupport::Concern + + class_methods do + def inherited(base) + super + base.items_holder = Avo::ItemsHolder.new + base.items_holder.instance_variable_get(:@items).replace items_holder.instance_variable_get(:@items).deep_dup + base.items_holder.invalid_fields.replace items_holder.invalid_fields.deep_dup + end + end + + included do + panel "Auditable" do + field :comment, as: :textarea, required: true, + help: "A comment explaining why this action was taken.
    Will be saved in the audit log.
    Must be more than 10 characters.", + only_on: %i[new edit] + end + end +end diff --git a/app/avo/resources/deletion_resource.rb b/app/avo/resources/deletion_resource.rb new file mode 100644 index 00000000000..fdf0cca5387 --- /dev/null +++ b/app/avo/resources/deletion_resource.rb @@ -0,0 +1,17 @@ +class DeletionResource < Avo::BaseResource + self.title = :id + self.includes = [:version] + # self.search_query = -> do + # scope.ransack(id_eq: params[:q], m: "or").result(distinct: false) + # end + + field :id, as: :id + # Fields generated from the model + field :created_at, as: :date_time, sortable: true, title: "Deleted At" + field :rubygem, as: :text + field :number, as: :text + field :platform, as: :text + field :user, as: :belongs_to + field :version, as: :belongs_to + # add fields here +end diff --git a/app/avo/resources/dependency_resource.rb b/app/avo/resources/dependency_resource.rb new file mode 100644 index 00000000000..a3751556d8f --- /dev/null +++ b/app/avo/resources/dependency_resource.rb @@ -0,0 +1,19 @@ +class DependencyResource < Avo::BaseResource + self.title = :id + self.includes = [] + # self.search_query = -> do + # scope.ransack(id_eq: params[:q], m: "or").result(distinct: false) + # end + + field :id, as: :id, link_to_resource: true + + field :version, as: :belongs_to + field :rubygem, as: :belongs_to + field :requirements, as: :text + field :unresolved_name, as: :text + + field :scope, as: :badge, + options: { + warning: "development" + } +end diff --git a/app/avo/resources/events_rubygem_event_resource.rb b/app/avo/resources/events_rubygem_event_resource.rb new file mode 100644 index 00000000000..00e1c212574 --- /dev/null +++ b/app/avo/resources/events_rubygem_event_resource.rb @@ -0,0 +1,28 @@ +class EventsRubygemEventResource < Avo::BaseResource + self.title = :cache_key + self.includes = %i[rubygem ip_address geoip_info] + self.model_class = ::Events::RubygemEvent + + field :id, as: :id, hide_on: :index + field :created_at, as: :date_time + + field :trace_id, as: :text, format_using: proc { + if value.present? + link_to( + view == :index ? "🔗" : value, + "https://app.datadoghq.com/logs?query=#{{ + :@traceid => value, + from_ts: (model.created_at - 12.hours).to_i * 1000, + to_ts: (model.created_at + 12.hours).to_i * 1000 + }.to_query}", + { target: :_blank, rel: :noopener } + ) + end + } + + field :tag, as: :text + field :rubygem, as: :belongs_to + field :ip_address, as: :belongs_to + field :geoip_info, as: :belongs_to + field :additional, as: :event_additional, show_on: :index +end diff --git a/app/avo/resources/events_user_event_resource.rb b/app/avo/resources/events_user_event_resource.rb new file mode 100644 index 00000000000..f1293d3fb1f --- /dev/null +++ b/app/avo/resources/events_user_event_resource.rb @@ -0,0 +1,28 @@ +class EventsUserEventResource < Avo::BaseResource + self.title = :cache_key + self.includes = %i[user ip_address geoip_info] + self.model_class = ::Events::UserEvent + + field :id, as: :id, hide_on: :index + + field :created_at, as: :date_time + field :trace_id, as: :text, format_using: proc { + if value.present? + link_to( + view == :index ? "🔗" : value, + "https://app.datadoghq.com/logs?query=#{{ + :@traceid => value, + from_ts: (model.created_at - 12.hours).to_i * 1000, + to_ts: (model.created_at + 12.hours).to_i * 1000 + }.to_query}", + { target: :_blank, rel: :noopener } + ) + end + } + + field :tag, as: :text + field :user, as: :belongs_to + field :ip_address, as: :belongs_to + field :geoip_info, as: :belongs_to + field :additional, as: :event_additional, show_on: :index +end diff --git a/app/avo/resources/gem_download_resource.rb b/app/avo/resources/gem_download_resource.rb new file mode 100644 index 00000000000..4cc08993f07 --- /dev/null +++ b/app/avo/resources/gem_download_resource.rb @@ -0,0 +1,27 @@ +class GemDownloadResource < Avo::BaseResource + self.title = :inspect + self.includes = %i[rubygem version] + + self.resolve_query_scope = lambda { |model_class:| + model_class.order(count: :desc) + } + + class SpecificityFilter < ScopeBooleanFilter; end + filter SpecificityFilter, arguments: { default: { for_versions: true, for_rubygems: true, total: true } } + + field :title, as: :text, link_to_resource: true do |model, _resource, _view| + if model.version + "#{model.version.full_name} (#{model.count.to_fs(:delimited)})" + elsif model.rubygem + "#{model.rubygem} (#{model.count.to_fs(:delimited)})" + else + "All Gems (#{model.count.to_fs(:delimited)})" + end + end + + field :rubygem, as: :belongs_to + field :version, as: :belongs_to + field :count, as: :number, sortable: true, index_text_align: :right, format_using: -> { value.to_fs(:delimited) }, default: 0 + + field :id, as: :id, hide_on: :index +end diff --git a/app/avo/resources/gem_name_reservation_resource.rb b/app/avo/resources/gem_name_reservation_resource.rb new file mode 100644 index 00000000000..0b44fa49e59 --- /dev/null +++ b/app/avo/resources/gem_name_reservation_resource.rb @@ -0,0 +1,10 @@ +class GemNameReservationResource < Avo::BaseResource + self.title = :name + self.includes = [] + self.search_query = lambda { + scope.where("name LIKE ?", "%#{params[:q]}%") + } + + field :id, as: :id + field :name, as: :text +end diff --git a/app/avo/resources/gem_typo_exception_resource.rb b/app/avo/resources/gem_typo_exception_resource.rb new file mode 100644 index 00000000000..8512e03d2f3 --- /dev/null +++ b/app/avo/resources/gem_typo_exception_resource.rb @@ -0,0 +1,15 @@ +class GemTypoExceptionResource < Avo::BaseResource + self.title = :name + self.includes = [] + self.search_query = lambda { + scope.where("name ILIKE ?", "%#{params[:q]}%") + } + + field :id, as: :id, hide_on: :index + # Fields generated from the model + field :name, as: :text, link_to_resource: true + field :info, as: :textarea + # add fields here + field :created_at, as: :date_time, sortable: true, readonly: true, only_on: %i[index show] + field :updated_at, as: :date_time, sortable: true, readonly: true, only_on: %i[index show] +end diff --git a/app/avo/resources/geoip_info_resource.rb b/app/avo/resources/geoip_info_resource.rb new file mode 100644 index 00000000000..b049c7b965c --- /dev/null +++ b/app/avo/resources/geoip_info_resource.rb @@ -0,0 +1,13 @@ +class GeoipInfoResource < Avo::BaseResource + self.title = :id + self.includes = [] + + field :continent_code, as: :text + field :country_code, as: :text + field :country_code3, as: :text + field :country_name, as: :text + field :region, as: :text + field :city, as: :text + + field :ip_addresses, as: :has_many +end diff --git a/app/avo/resources/ip_address_resource.rb b/app/avo/resources/ip_address_resource.rb new file mode 100644 index 00000000000..a6c99cc7271 --- /dev/null +++ b/app/avo/resources/ip_address_resource.rb @@ -0,0 +1,20 @@ +class IpAddressResource < Avo::BaseResource + self.title = :ip_address + self.includes = [] + + self.hide_from_global_search = true + self.search_query = lambda { + scope.where("ip_address <<= inet ?", params[:q]) + } + + field :id, as: :id + + field :ip_address, as: :text + field :hashed_ip_address, as: :textarea + field :geoip_info, as: :json_viewer + + tabs style: :pills do + field :user_events, as: :has_many + field :rubygem_events, as: :has_many + end +end diff --git a/app/avo/resources/link_verification_resource.rb b/app/avo/resources/link_verification_resource.rb new file mode 100644 index 00000000000..ee60c06d904 --- /dev/null +++ b/app/avo/resources/link_verification_resource.rb @@ -0,0 +1,19 @@ +class LinkVerificationResource < Avo::BaseResource + self.title = :id + self.includes = [] + # self.search_query = -> do + # scope.ransack(id_eq: params[:q], m: "or").result(distinct: false) + # end + + field :id, as: :id + # Fields generated from the model + field :linkable, as: :belongs_to, + polymorphic_as: :linkable, + types: %w[Rubygem] + field :uri, as: :text + field :verified?, as: :boolean + field :last_verified_at, as: :date_time + field :last_failure_at, as: :date_time + field :failures_since_last_verification, as: :number + # add fields here +end diff --git a/app/avo/resources/linkset_resource.rb b/app/avo/resources/linkset_resource.rb new file mode 100644 index 00000000000..ac5966a45bf --- /dev/null +++ b/app/avo/resources/linkset_resource.rb @@ -0,0 +1,12 @@ +class LinksetResource < Avo::BaseResource + self.title = :id + self.includes = [:rubygem] + self.visible_on_sidebar = false + + field :id, as: :id, link_to_resource: true + field :rubygem, as: :belongs_to + + Linkset::LINKS.each do |link| + field link, as: :text, format_using: -> { link_to value, value if value.present? } + end +end diff --git a/app/avo/resources/log_ticket_resource.rb b/app/avo/resources/log_ticket_resource.rb new file mode 100644 index 00000000000..1a6c88419e2 --- /dev/null +++ b/app/avo/resources/log_ticket_resource.rb @@ -0,0 +1,18 @@ +class LogTicketResource < Avo::BaseResource + self.title = :id + self.includes = [] + + class BackendFilter < ScopeBooleanFilter; end + filter BackendFilter, arguments: { default: LogTicket.backends.transform_values { true } } + + class StatusFilter < ScopeBooleanFilter; end + filter StatusFilter, arguments: { default: LogTicket.statuses.transform_values { true } } + + field :id, as: :id, link_to_resource: true + + field :key, as: :text + field :directory, as: :text + field :backend, as: :select, enum: LogTicket.backends + field :status, as: :select, enum: LogTicket.statuses + field :processed_count, as: :number, sortable: true +end diff --git a/app/avo/resources/maintenance_tasks_run_resource.rb b/app/avo/resources/maintenance_tasks_run_resource.rb new file mode 100644 index 00000000000..0f553fdb3a5 --- /dev/null +++ b/app/avo/resources/maintenance_tasks_run_resource.rb @@ -0,0 +1,29 @@ +class MaintenanceTasksRunResource < Avo::BaseResource + self.title = :id + self.includes = [] + self.model_class = ::MaintenanceTasks::Run + # self.search_query = -> do + # scope.ransack(id_eq: params[:q], m: "or").result(distinct: false) + # end + + class StatusFilter < ScopeBooleanFilter; end + filter StatusFilter, arguments: { default: MaintenanceTasks::Run.statuses.transform_values { true } } + + field :id, as: :id + # Fields generated from the model + field :task_name, as: :text + field :started_at, as: :date_time, sortable: true + field :ended_at, as: :date_time, sortable: true + field :time_running, as: :number, sortable: true + field :tick_count, as: :number + field :tick_total, as: :number + field :job_id, as: :text + field :cursor, as: :number + field :status, as: :select, enum: MaintenanceTasks::Run.statuses + field :error_class, as: :text + field :error_message, as: :text + field :backtrace, as: :textarea + field :arguments, as: :textarea + field :lock_version, as: :number + # add fields here +end diff --git a/app/avo/resources/membership_resource.rb b/app/avo/resources/membership_resource.rb new file mode 100644 index 00000000000..34ade886951 --- /dev/null +++ b/app/avo/resources/membership_resource.rb @@ -0,0 +1,11 @@ +class MembershipResource < Avo::BaseResource + self.title = :id + self.includes = [] + + class ConfirmedFilter < ScopeBooleanFilter; end + filter ConfirmedFilter, arguments: { default: { confirmed: true, unconfirmed: false } } + + field :id, as: :id + field :user, as: :belongs_to + field :organization, as: :belongs_to +end diff --git a/app/avo/resources/oidc_api_key_role_resource.rb b/app/avo/resources/oidc_api_key_role_resource.rb new file mode 100644 index 00000000000..3f59087e912 --- /dev/null +++ b/app/avo/resources/oidc_api_key_role_resource.rb @@ -0,0 +1,36 @@ +class OIDCApiKeyRoleResource < Avo::BaseResource + self.title = :token + self.includes = [] + self.model_class = ::OIDC::ApiKeyRole + # self.search_query = -> do + # scope.ransack(id_eq: params[:q], m: "or").result(distinct: false) + # end + + field :token, as: :text, link_to_resource: true, readonly: true + field :id, as: :id, link_to_resource: true, hide_on: :index + # Fields generated from the model + field :name, as: :text + field :provider, as: :belongs_to + field :user, as: :belongs_to, searchable: true + field :api_key_permissions, as: :nested do + field :valid_for, as: :text, format_using: -> { value&.iso8601 } + field :scopes, as: :tags, suggestions: ApiKey::API_SCOPES.map { { label: _1, value: _1 } }, enforce_suggestions: true + field :gems, as: :tags, suggestions: -> { Rubygem.limit(10).pluck(:name).map { { value: _1, label: _1 } } } + end + field :access_policy, as: :nested do + field :statements, as: :array_of, field: :nested do + field :effect, as: :select, options: { "Allow" => "allow" }, default: "Allow" + field :principal, as: :nested, field_options: { stacked: false } do + field :oidc, as: :text + end + field :conditions, as: :array_of, field: :nested, field_options: { stacked: false } do + field :operator, as: :select, options: OIDC::AccessPolicy::Statement::Condition::OPERATORS.index_by(&:titleize) + field :claim, as: :text + field :value, as: :text + end + end + end + + field :id_tokens, as: :has_many + # add fields here +end diff --git a/app/avo/resources/oidc_id_token_resource.rb b/app/avo/resources/oidc_id_token_resource.rb new file mode 100644 index 00000000000..bda90ed7025 --- /dev/null +++ b/app/avo/resources/oidc_id_token_resource.rb @@ -0,0 +1,23 @@ +class OIDCIdTokenResource < Avo::BaseResource + self.title = :id + self.includes = [] + self.model_class = ::OIDC::IdToken + # self.search_query = -> do + # scope.ransack(id_eq: params[:q], m: "or").result(distinct: false) + # end + + field :id, as: :id + # Fields generated from the model + field :api_key_role, as: :belongs_to + field :provider, as: :has_one + field :api_key, as: :has_one + + heading "JWT" + field :claims, as: :key_value, stacked: true do + model.jwt.fetch("claims") + end + field :header, as: :key_value, stacked: true do + model.jwt.fetch("header") + end + # add fields here +end diff --git a/app/avo/resources/oidc_pending_trusted_publisher_resource.rb b/app/avo/resources/oidc_pending_trusted_publisher_resource.rb new file mode 100644 index 00000000000..f546d3a8df1 --- /dev/null +++ b/app/avo/resources/oidc_pending_trusted_publisher_resource.rb @@ -0,0 +1,16 @@ +class OIDCPendingTrustedPublisherResource < Avo::BaseResource + self.title = :id + self.includes = [] + self.model_class = ::OIDC::PendingTrustedPublisher + + class ExpiredFilter < ScopeBooleanFilter; end + filter ExpiredFilter, arguments: { default: { expired: false, unexpired: true } } + + field :id, as: :id + # Fields generated from the model + field :rubygem_name, as: :text + field :user, as: :belongs_to + field :trusted_publisher, as: :belongs_to, polymorphic_as: :trusted_publisher + field :expires_at, as: :date_time + # add fields here +end diff --git a/app/avo/resources/oidc_provider_resource.rb b/app/avo/resources/oidc_provider_resource.rb new file mode 100644 index 00000000000..8a2fa0fe6d4 --- /dev/null +++ b/app/avo/resources/oidc_provider_resource.rb @@ -0,0 +1,20 @@ +class OIDCProviderResource < Avo::BaseResource + self.title = :issuer + self.includes = [] + self.model_class = ::OIDC::Provider + + action RefreshOIDCProvider + + # Fields generated from the model + field :issuer, as: :text, link_to_resource: true + field :configuration, as: :nested do + visible_on = %i[edit new] + OIDC::Provider::Configuration.then { (_1.required_attributes + _1.optional_attributes) - fields.map(&:id) }.each do |k| + field k, as: (k.to_s.end_with?("s_supported") ? :tags : :text), visible: ->(_) { visible_on.include?(view) || value.send(k).present? } + end + end + field :jwks, as: :array_of, field: :json_viewer, hide_on: :index + field :api_key_roles, as: :has_many + # add fields here + field :id, as: :id +end diff --git a/app/avo/resources/oidc_rubygem_trusted_publisher_resource.rb b/app/avo/resources/oidc_rubygem_trusted_publisher_resource.rb new file mode 100644 index 00000000000..96c63c43096 --- /dev/null +++ b/app/avo/resources/oidc_rubygem_trusted_publisher_resource.rb @@ -0,0 +1,11 @@ +class OIDCRubygemTrustedPublisherResource < Avo::BaseResource + self.title = :id + self.includes = [:trusted_publisher] + self.model_class = ::OIDC::RubygemTrustedPublisher + + field :id, as: :id + # Fields generated from the model + field :rubygem, as: :belongs_to + field :trusted_publisher, as: :belongs_to, polymorphic_as: :trusted_publisher + # add fields here +end diff --git a/app/avo/resources/oidc_trusted_publisher_github_action_resource.rb b/app/avo/resources/oidc_trusted_publisher_github_action_resource.rb new file mode 100644 index 00000000000..0ea00fd83e4 --- /dev/null +++ b/app/avo/resources/oidc_trusted_publisher_github_action_resource.rb @@ -0,0 +1,19 @@ +class OIDCTrustedPublisherGitHubActionResource < Avo::BaseResource + self.title = :name + self.includes = [] + self.model_class = ::OIDC::TrustedPublisher::GitHubAction + + field :id, as: :id + # Fields generated from the model + field :repository_owner, as: :text + field :repository_name, as: :text + field :repository_owner_id, as: :text + field :workflow_filename, as: :text + field :environment, as: :text + # add fields here + # + field :rubygem_trusted_publishers, as: :has_many + field :pending_trusted_publishers, as: :has_many + field :rubygems, as: :has_many, through: :rubygem_trusted_publishers + field :api_keys, as: :has_many, inverse_of: :owner +end diff --git a/app/avo/resources/organization_resource.rb b/app/avo/resources/organization_resource.rb new file mode 100644 index 00000000000..592fdb766d8 --- /dev/null +++ b/app/avo/resources/organization_resource.rb @@ -0,0 +1,18 @@ +class OrganizationResource < Avo::BaseResource + self.title = :name + self.includes = [] + self.search_query = lambda { + scope.where("name LIKE ? OR handle LIKE ?", "%#{params[:q]}%", "%#{params[:q]}%") + } + self.unscoped_queries_on_index = true + + class DeletedFilter < ScopeBooleanFilter; end + filter DeletedFilter, arguments: { default: { not_deleted: true, deleted: false } } + + field :id, as: :id + # Fields generated from the model + field :handle, as: :text + field :name, as: :text + field :deleted_at, as: :date_time + # add fields here +end diff --git a/app/avo/resources/ownership_resource.rb b/app/avo/resources/ownership_resource.rb new file mode 100644 index 00000000000..ed4a6f686bd --- /dev/null +++ b/app/avo/resources/ownership_resource.rb @@ -0,0 +1,30 @@ +class OwnershipResource < Avo::BaseResource + self.title = :cache_key + self.includes = [] + + class ConfirmedFilter < ScopeBooleanFilter; end + filter ConfirmedFilter, arguments: { default: { confirmed: true, unconfirmed: true } } + + field :id, as: :id, link_to_resource: true + + field :user, as: :belongs_to + field :rubygem, as: :belongs_to + + heading "Token" + + field :token, as: :text, visible: ->(_) { false } + field :token_expires_at, as: :date_time + field :api_key_rubygem_scopes, as: :has_many + + heading "Notifications" + + field :push_notifier, as: :boolean + field :owner_notifier, as: :boolean + field :ownership_request_notifier, as: :boolean + + heading "Authorization" + + field :authorizer, as: :belongs_to + field :confirmed_at, as: :date_time + field :role, as: :select, enum: Ownership.roles +end diff --git a/app/avo/resources/rubygem_resource.rb b/app/avo/resources/rubygem_resource.rb new file mode 100644 index 00000000000..09636f782de --- /dev/null +++ b/app/avo/resources/rubygem_resource.rb @@ -0,0 +1,47 @@ +class RubygemResource < Avo::BaseResource + self.title = :name + self.includes = [] + self.search_query = lambda { + scope.where("name LIKE ?", "%#{params[:q]}%") + } + + action ReleaseReservedNamespace + action AddOwner + action YankRubygem + action UploadInfoFile + action UploadNamesFile + action UploadVersionsFile + + class IndexedFilter < ScopeBooleanFilter; end + filter IndexedFilter, arguments: { default: { with_versions: true, without_versions: true } } + + # Fields generated from the model + field :name, as: :text, link_to_resource: true + field :indexed, as: :boolean + field :slug, as: :text, hide_on: :index + field :id, as: :id, hide_on: :index + field :protected_days, as: :number, hide_on: :index + + tabs style: :pills do + field :versions, as: :has_many + field :latest_version, as: :has_one + + field :ownerships, as: :has_many + field :ownerships_including_unconfirmed, as: :has_many + field :ownership_calls, as: :has_many + field :ownership_requests, as: :has_many + + field :subscriptions, as: :has_many + field :subscribers, as: :has_many, through: :subscriptions + + field :web_hooks, as: :has_many + field :linkset, as: :has_one + field :gem_download, as: :has_one + + field :link_verifications, as: :has_many + field :oidc_rubygem_trusted_publishers, as: :has_many + + field :events, as: :has_many + field :audits, as: :has_many + end +end diff --git a/app/avo/resources/sendgrid_event_resource.rb b/app/avo/resources/sendgrid_event_resource.rb new file mode 100644 index 00000000000..9328ed102db --- /dev/null +++ b/app/avo/resources/sendgrid_event_resource.rb @@ -0,0 +1,25 @@ +class SendgridEventResource < Avo::BaseResource + self.title = :sendgrid_id + self.includes = [] + # self.search_query = -> do + # scope.ransack(id_eq: params[:q], m: "or").result(distinct: false) + # end + + class StatusFilter < ScopeBooleanFilter; end + filter StatusFilter, arguments: { default: SendgridEvent.statuses.transform_values { true } } + + class EventTypeFilter < ScopeBooleanFilter; end + filter EventTypeFilter, arguments: { default: SendgridEvent.event_types.transform_values { true } } + + filter EmailFilter + + field :id, as: :id, hide_on: :index + # Fields generated from the model + field :sendgrid_id, as: :text, link_to_resource: true + field :email, as: :text + field :event_type, as: :text + field :occurred_at, as: :date_time, sortable: true + field :payload, as: :json_viewer + field :status, as: :select, enum: SendgridEvent.statuses + # add fields here +end diff --git a/app/avo/resources/user_resource.rb b/app/avo/resources/user_resource.rb new file mode 100644 index 00000000000..acba5dfe213 --- /dev/null +++ b/app/avo/resources/user_resource.rb @@ -0,0 +1,80 @@ +class UserResource < Avo::BaseResource + self.title = :name + self.includes = [] + self.search_query = lambda { + scope.where("email LIKE ? OR handle LIKE ?", "%#{params[:q]}%", "%#{params[:q]}%") + } + self.unscoped_queries_on_index = true + + class DeletedFilter < ScopeBooleanFilter; end + filter DeletedFilter, arguments: { default: { not_deleted: true, deleted: false } } + + action BlockUser + action CreateUser + action ChangeUserEmail + action ResetApiKey + action ResetUser2fa + action YankRubygemsForUser + action YankUser + + field :id, as: :id + # Fields generated from the model + field :email, as: :text + field :gravatar, + as: :gravatar, + rounded: true, + size: 48 do |_, _, _| + model.email + end + + field :email_confirmed, as: :boolean + + field :email_reset, as: :boolean + field :handle, as: :text + field :public_email, as: :boolean + field :twitter_username, as: :text, as_html: true, format_using: -> { link_to value, "https://twitter.com/#{value}", target: :_blank, rel: :noopener if value.present? } + field :unconfirmed_email, as: :text + + field :mail_fails, as: :number + field :blocked_email, as: :text + + field :deleted_at, as: :date_time + + tabs style: :pills do + tab "Auth" do + field :encrypted_password, as: :password, visible: ->(_) { false } + field :totp_seed, as: :text, visible: ->(_) { false } + field :mfa_seed, as: :text, visible: ->(_) { false } # legacy field + field :mfa_level, as: :select, enum: ::User.mfa_levels + field :mfa_recovery_codes, as: :text, visible: ->(_) { false } + field :mfa_hashed_recovery_codes, as: :text, visible: ->(_) { false } + field :webauthn_id, as: :text + field :remember_token_expires_at, as: :date_time + field :api_key, as: :text, visible: ->(_) { false } + field :confirmation_token, as: :text, visible: ->(_) { false } + field :remember_token, as: :text, visible: ->(_) { false } + field :salt, as: :text, visible: ->(_) { false } + field :token, as: :text, visible: ->(_) { false } + field :token_expires_at, as: :date_time + end + field :ownerships, as: :has_many + field :rubygems, as: :has_many, through: :ownerships + field :memberships, as: :has_many + field :organizations, as: :has_many, through: :memberships + field :subscriptions, as: :has_many + field :subscribed_gems, as: :has_many, through: :subscriptions + field :deletions, as: :has_many + field :web_hooks, as: :has_many + field :unconfirmed_ownerships, as: :has_many + field :api_keys, as: :has_many, name: "API Keys" + field :ownership_calls, as: :has_many + field :ownership_requests, as: :has_many + field :pushed_versions, as: :has_many + field :oidc_api_key_roles, as: :has_many + field :webauthn_credentials, as: :has_many + field :webauthn_verification, as: :has_one + + field :audits, as: :has_many + field :events, as: :has_many + end +end diff --git a/app/avo/resources/version_resource.rb b/app/avo/resources/version_resource.rb new file mode 100644 index 00000000000..62d853fb69c --- /dev/null +++ b/app/avo/resources/version_resource.rb @@ -0,0 +1,70 @@ +class VersionResource < Avo::BaseResource + self.title = :full_name + self.includes = [:rubygem] + self.search_query = lambda { + scope.where("full_name LIKE ?", "#{params[:q]}%") + } + + action RestoreVersion + action VersionAfterWrite + + class IndexedFilter < ScopeBooleanFilter; end + filter IndexedFilter, arguments: { default: { indexed: true, yanked: true } } + + field :full_name, as: :text, link_to_resource: true + field :id, as: :id, hide_on: :index, as_html: true do |_id, *_args| + link_to model.id, main_app.rubygem_version_url(model.rubygem.slug, model.slug) + end + + field :rubygem, as: :belongs_to + field :slug, as: :text, hide_on: :index + field :number, as: :text + field :platform, as: :text + + field :canonical_number, as: :text + + field :indexed, as: :boolean + field :prerelease, as: :boolean + field :position, as: :number + field :latest, as: :boolean + + field :yanked_at, as: :date_time, sortable: true + + field :pusher, as: :belongs_to, class: "User" + field :pusher_api_key, as: :belongs_to, class: "ApiKey" + + tabs do + tab "Metadata", description: "Metadata that comes from the gemspec" do + panel do + field :summary, as: :textarea + field :description, as: :textarea + field :authors, as: :textarea + field :licenses, as: :textarea + field :cert_chain, as: :textarea + field :built_at, as: :date_time, sortable: true + field :metadata, as: :key_value, stacked: true + end + end + + tab "Runtime information" do + panel do + field :size, as: :number, sortable: true + field :requirements, as: :textarea + field :required_ruby_version, as: :text + field :sha256, as: :text + field :required_rubygems_version, as: :text + end + end + + tab "API" do + panel do + field :info_checksum, as: :text + field :yanked_info_checksum, as: :text + end + end + + field :dependencies, as: :has_many + field :gem_download, as: :has_one, name: "Downloads" + field :deletion, as: :has_one + end +end diff --git a/app/avo/resources/web_hook_resource.rb b/app/avo/resources/web_hook_resource.rb new file mode 100644 index 00000000000..93c9e703a45 --- /dev/null +++ b/app/avo/resources/web_hook_resource.rb @@ -0,0 +1,35 @@ +class WebHookResource < Avo::BaseResource + self.title = :id + self.includes = %i[user rubygem] + + action DeleteWebhook + class EnabledFilter < ScopeBooleanFilter; end + filter EnabledFilter, arguments: { default: { enabled: true, disabled: false } } + class GlobalFilter < ScopeBooleanFilter; end + filter GlobalFilter, arguments: { default: { global: true, specific: true } } + + field :id, as: :id, link_to_resource: true + + field :url, as: :text + field :enabled?, as: :boolean + field :failure_count, as: :number, sortable: true, index_text_align: :right + field :user, as: :belongs_to + field :rubygem, as: :belongs_to + field :global?, as: :boolean + + field :hook_relay_stream, as: :text do + stream_name = "webhook_id-#{model.id}" + link_to stream_name, "https://app.hookrelay.dev/hooks/#{ENV['HOOK_RELAY_HOOK_ID']}?started_at=P1W&stream=#{stream_name}" + end + + field :disabled_reason, as: :text + field :disabled_at, as: :date_time, sortable: true + field :last_success, as: :date_time, sortable: true + field :last_failure, as: :date_time, sortable: true + field :successes_since_last_failure, as: :number, sortable: true + field :failures_since_last_success, as: :number, sortable: true + + tabs style: :pills do + field :audits, as: :has_many + end +end diff --git a/app/avo/resources/webauthn_credential_resource.rb b/app/avo/resources/webauthn_credential_resource.rb new file mode 100644 index 00000000000..b6711b63eab --- /dev/null +++ b/app/avo/resources/webauthn_credential_resource.rb @@ -0,0 +1,13 @@ +class WebauthnCredentialResource < Avo::BaseResource + self.title = :id + self.includes = [] + + field :id, as: :id + # Fields generated from the model + field :external_id, as: :text + field :public_key, as: :text + field :nickname, as: :text + field :sign_count, as: :number + field :user, as: :belongs_to + # add fields here +end diff --git a/app/avo/resources/webauthn_verification_resource.rb b/app/avo/resources/webauthn_verification_resource.rb new file mode 100644 index 00000000000..5834540ac89 --- /dev/null +++ b/app/avo/resources/webauthn_verification_resource.rb @@ -0,0 +1,13 @@ +class WebauthnVerificationResource < Avo::BaseResource + self.title = :id + self.includes = [] + + field :id, as: :id + # Fields generated from the model + field :path_token, as: :text + field :path_token_expires_at, as: :date_time + field :otp, as: :text + field :otp_expires_at, as: :date_time + field :user, as: :belongs_to + # add fields here +end diff --git a/app/components/avo/audited_changes_record_diff/show_component.html.erb b/app/components/avo/audited_changes_record_diff/show_component.html.erb new file mode 100644 index 00000000000..82c6d99e068 --- /dev/null +++ b/app/components/avo/audited_changes_record_diff/show_component.html.erb @@ -0,0 +1,14 @@ +<%= render Avo::PanelComponent.new(title: title_link, classes: %w[w-full]) do |c| %> + <% c.with_body do %> + <% next unless authorized? %> + +
    + <% each_field do |type, component| %> + <%= tag.div class: change_type_row_classes(type) do %> + <%= change_type_icon type %> + <%= render component %> + <% end %> + <% end %> +
    + <% end %> +<% end %> \ No newline at end of file diff --git a/app/components/avo/audited_changes_record_diff/show_component.rb b/app/components/avo/audited_changes_record_diff/show_component.rb new file mode 100644 index 00000000000..e898fe4593f --- /dev/null +++ b/app/components/avo/audited_changes_record_diff/show_component.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +class Avo::AuditedChangesRecordDiff::ShowComponent < ViewComponent::Base + def initialize(gid:, changes:, unchanged:, view:, user:) + super + @gid = gid + @changes = changes + @unchanged = unchanged + @user = user + @view = view + + global_id = GlobalID.parse(gid) + model = begin + global_id.find + rescue ActiveRecord::RecordNotFound + global_id.model_class.new(id: global_id.model_id) + end + return unless (@resource = Avo::App.get_resource_by_model_name(global_id.model_class)) + @resource.hydrate(model:, user:, view:) + + @old_resource = resource.dup.hydrate(model: resource.model_class.new(**unchanged, **changes.transform_values(&:first)), view:) + @new_resource = resource.dup.hydrate(model: resource.model_class.new(**unchanged, **changes.transform_values(&:last)), view:) + end + + def render? + @resource.present? + end + + attr_reader :gid, :changes, :unchanged, :user, :resource, :old_resource, :new_resource, :view + + def sorted_fields + @resource.fields + .reject { _1.is_a?(Avo::Fields::HasBaseField) } + .sort_by.with_index { |f, i| [changes.key?(f.id.to_s) ? -1 : 1, i] } + end + + def each_field # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity + deleted = changes.key?("id") && changes.dig("id", 1).nil? + new_record = changes.key?("id") && changes.dig("id", 0).nil? + + sorted_fields.each do |field| + database_id = field.database_id&.to_s || "#{field.id}_id" + + unless field.visible? + if changes.key?(database_id) + # dummy field to avoid ever printing out the contents... we just want the label + yield (deleted ? :old : :changed), Avo::Fields::BooleanField::ShowComponent.new(field: field) + end + next + end + + if changes.key?(database_id) + yield :new, component_for_field(field, new_resource) unless deleted + yield :old, component_for_field(field, old_resource) unless new_record + elsif unchanged.key?(database_id) + yield :unchanged, component_for_field(field, new_resource) + end + end + end + + def component_for_field(field, resource) + field = field.hydrate(model: resource.model, view:) + field.component_for_view(view).new(field:, resource:) + end + + def authorized? + Pundit.policy!(user, [:admin, resource.model]).avo_show? + end + + def title_link + link_to(resource.model_title, resource.record_path) + end + + def change_type_icon(type) + case type + when :changed + helpers.svg("arrows-right-left", class: %w[h-4]) + when :new + helpers.svg("forward", class: %w[h-4]) + when :old + helpers.svg("backward", class: %w[h-4]) + end + end + + def change_type_row_classes(type) + case type + when :changed + %w[bg-orange-400] + when :new + %w[bg-green-500] + when :old + %w[bg-red-400] + else [] + end + %w[flex flex-row items-baseline] + end +end diff --git a/app/components/avo/fields/array_of_field/edit_component.html.erb b/app/components/avo/fields/array_of_field/edit_component.html.erb new file mode 100644 index 00000000000..cd96cb09ac7 --- /dev/null +++ b/app/components/avo/fields/array_of_field/edit_component.html.erb @@ -0,0 +1,31 @@ +<%= field_wrapper **field_wrapper_args, stacked: true, data: {} do %> + <%= content_tag :div, data: { controller: 'nested-form', nested_form_wrapper_selector_value: '.nested-form-wrapper' } do %> + + + <% field.members.each do |f| %> +
    + <%= a_link 'javascript:void(0);', icon: 'trash', color: :red, style: :text, data: {action: "click->nested-form#remove"} do %> + Remove <%= @field.name.singularize %> + <% end %> + <%= render f.component_for_view(view).new(field: f, form:, view:) %> + <% if field.model && field.model.errors.include?(f.id) %> +
    <%= field.model.errors.messages_for(f.id).to_sentence %>
    + <% end %> +
    + <% end %> + +
    + + + <%= a_link 'javascript:void(0);', icon: 'plus', color: :primary, style: :outline, data: {action: "click->nested-form#add"} do %> + Add another <%= @field.name.singularize %> + <% end %> + <% end %> +<% end %> diff --git a/app/components/avo/fields/array_of_field/edit_component.rb b/app/components/avo/fields/array_of_field/edit_component.rb new file mode 100644 index 00000000000..f933f72d47b --- /dev/null +++ b/app/components/avo/fields/array_of_field/edit_component.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class Avo::Fields::ArrayOfField::EditComponent < Avo::Fields::EditComponent + include Avo::ApplicationHelper +end diff --git a/app/components/avo/fields/array_of_field/show_component.html.erb b/app/components/avo/fields/array_of_field/show_component.html.erb new file mode 100644 index 00000000000..296f7245b15 --- /dev/null +++ b/app/components/avo/fields/array_of_field/show_component.html.erb @@ -0,0 +1,7 @@ +<%= field_wrapper **field_wrapper_args, stacked: true, data: {} do %> +
    + <% field.members.each do |f| %> + <%= render f.component_for_view(view).new(field: f) %> + <% end %> +
    +<% end %> diff --git a/app/components/avo/fields/array_of_field/show_component.rb b/app/components/avo/fields/array_of_field/show_component.rb new file mode 100644 index 00000000000..c24203cfa51 --- /dev/null +++ b/app/components/avo/fields/array_of_field/show_component.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class Avo::Fields::ArrayOfField::ShowComponent < Avo::Fields::ShowComponent +end diff --git a/app/components/avo/fields/audited_changes_field/show_component.html.erb b/app/components/avo/fields/audited_changes_field/show_component.html.erb new file mode 100644 index 00000000000..9b4ee42a56e --- /dev/null +++ b/app/components/avo/fields/audited_changes_field/show_component.html.erb @@ -0,0 +1,11 @@ +<%= field_wrapper **field_wrapper_args, full_width: true do %> + <% records&.each do |gid, changes, unchanged| %> + <%= render Avo::AuditedChangesRecordDiff::ShowComponent.new( + gid:, + changes:, + unchanged:, + view:, + user: resource.user, + ) %> + <% end %> +<% end %> diff --git a/app/components/avo/fields/audited_changes_field/show_component.rb b/app/components/avo/fields/audited_changes_field/show_component.rb new file mode 100644 index 00000000000..f71fa66f91d --- /dev/null +++ b/app/components/avo/fields/audited_changes_field/show_component.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Avo::Fields::AuditedChangesField::ShowComponent < Avo::Fields::ShowComponent + def records + field.value["records"]&.map do |gid, body| + changes, unchanged = body.values_at("changes", "unchanged") + + [gid, changes, unchanged] + end + end +end diff --git a/app/components/avo/fields/event_additional_field/index_component.html.erb b/app/components/avo/fields/event_additional_field/index_component.html.erb new file mode 100644 index 00000000000..cd04797d856 --- /dev/null +++ b/app/components/avo/fields/event_additional_field/index_component.html.erb @@ -0,0 +1,3 @@ +<%= index_field_wrapper **field_wrapper_args do %> + <%= @field.value %> +<% end %> diff --git a/app/components/avo/fields/event_additional_field/index_component.rb b/app/components/avo/fields/event_additional_field/index_component.rb new file mode 100644 index 00000000000..6610c849bf0 --- /dev/null +++ b/app/components/avo/fields/event_additional_field/index_component.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class Avo::Fields::EventAdditionalField::IndexComponent < Avo::Fields::IndexComponent +end diff --git a/app/components/avo/fields/gravatar_field/show_component.html.erb b/app/components/avo/fields/gravatar_field/show_component.html.erb new file mode 100644 index 00000000000..50bd2304059 --- /dev/null +++ b/app/components/avo/fields/gravatar_field/show_component.html.erb @@ -0,0 +1,8 @@ +<%= field_wrapper **field_wrapper_args do %> + <%= render Avo::Fields::Common::GravatarViewerComponent.new( + md5: @field.md5, + default: @field.default, + size: 144, + ) + %> +<% end %> diff --git a/app/components/avo/fields/gravatar_field/show_component.rb b/app/components/avo/fields/gravatar_field/show_component.rb new file mode 100644 index 00000000000..b5479a3eb89 --- /dev/null +++ b/app/components/avo/fields/gravatar_field/show_component.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class Avo::Fields::GravatarField::ShowComponent < Avo::Fields::ShowComponent +end diff --git a/app/components/avo/fields/json_viewer_field/edit_component.rb b/app/components/avo/fields/json_viewer_field/edit_component.rb new file mode 100644 index 00000000000..2c59666166f --- /dev/null +++ b/app/components/avo/fields/json_viewer_field/edit_component.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class Avo::Fields::JsonViewerField::EditComponent < Avo::Fields::CodeField::EditComponent +end diff --git a/app/components/avo/fields/json_viewer_field/index_component.html.erb b/app/components/avo/fields/json_viewer_field/index_component.html.erb new file mode 100644 index 00000000000..433c287f0a1 --- /dev/null +++ b/app/components/avo/fields/json_viewer_field/index_component.html.erb @@ -0,0 +1,5 @@ +<%= index_field_wrapper **field_wrapper_args do %> + <% if @field.value.present? %> +
    <%= content_tag :code, YAML.dump(JSON.load(@field.value).compact).sub(/\A---(\R| {})/, "") %>
    + <% end %> +<% end %> diff --git a/app/components/avo/fields/json_viewer_field/index_component.rb b/app/components/avo/fields/json_viewer_field/index_component.rb new file mode 100644 index 00000000000..65ffe060d03 --- /dev/null +++ b/app/components/avo/fields/json_viewer_field/index_component.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class Avo::Fields::JsonViewerField::IndexComponent < Avo::Fields::IndexComponent +end diff --git a/app/components/avo/fields/json_viewer_field/show_component.rb b/app/components/avo/fields/json_viewer_field/show_component.rb new file mode 100644 index 00000000000..18638f1be55 --- /dev/null +++ b/app/components/avo/fields/json_viewer_field/show_component.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class Avo::Fields::JsonViewerField::ShowComponent < Avo::Fields::CodeField::ShowComponent +end diff --git a/app/components/avo/fields/nested_field/edit_component.html.erb b/app/components/avo/fields/nested_field/edit_component.html.erb new file mode 100644 index 00000000000..f4a7c9628fe --- /dev/null +++ b/app/components/avo/fields/nested_field/edit_component.html.erb @@ -0,0 +1,21 @@ +<%= field_wrapper **field_wrapper_args, class: "nested-form-wrapper", data: {} do %> + <% form.fields_for field.id, field.value do |form| %> + <% field.get_fields.each do |f| %> + <%= render f.hydrate(view:, model: field.value, resource:).component_for_view(view).new(field: f, form:, view:) %> + <% if field.value && field.value.errors.include?(f.id) %> +
    <%= field.value.errors.messages_for(f.id).to_sentence %>
    + <% end %> + <% end %> + <% end %> + <% if field.value && field.value.errors.include?(:base) %> +
    + <% field.value.errors.messages_for(:base).each do |attr, messages| %> +
      + <% messages.each do |m|%> +
    • <%= m %>
    • + <% end %> +
        + <% end %> +
    + <% end %> +<% end %> diff --git a/app/components/avo/fields/nested_field/edit_component.rb b/app/components/avo/fields/nested_field/edit_component.rb new file mode 100644 index 00000000000..6495adb5b70 --- /dev/null +++ b/app/components/avo/fields/nested_field/edit_component.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class Avo::Fields::NestedField::EditComponent < Avo::Fields::EditComponent +end diff --git a/app/components/avo/fields/nested_field/index_component.html.erb b/app/components/avo/fields/nested_field/index_component.html.erb new file mode 100644 index 00000000000..e9ba7bd9385 --- /dev/null +++ b/app/components/avo/fields/nested_field/index_component.html.erb @@ -0,0 +1,13 @@ +<%= index_field_wrapper **field_wrapper_args do %> + <% if @field.value.present? %> + <% resource = field_wrapper_args.fetch(:resource) or raise "missing resource for #{self}" %> + + <% field.get_fields.each do |f| %> + + + <%= render f.hydrate(view:, model: field.value, resource:).component_for_view(view).new(field: f, resource:, flush: true) %> + + <% end %> +
    <%= f.name %>
    + <% end %> +<% end %> diff --git a/app/components/avo/fields/nested_field/index_component.rb b/app/components/avo/fields/nested_field/index_component.rb new file mode 100644 index 00000000000..d944f0442a5 --- /dev/null +++ b/app/components/avo/fields/nested_field/index_component.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class Avo::Fields::NestedField::IndexComponent < Avo::Fields::IndexComponent +end diff --git a/app/components/avo/fields/nested_field/show_component.html.erb b/app/components/avo/fields/nested_field/show_component.html.erb new file mode 100644 index 00000000000..e4e457db03e --- /dev/null +++ b/app/components/avo/fields/nested_field/show_component.html.erb @@ -0,0 +1,7 @@ +<%= field_wrapper **field_wrapper_args, data: {} do %> +
    + <% field.get_fields.each do |f| %> + <%= render f.hydrate(view:, model: field.value, resource:).component_for_view(view).new(field: f, resource:) %> + <% end %> +
    +<% end %> diff --git a/app/components/avo/fields/nested_field/show_component.rb b/app/components/avo/fields/nested_field/show_component.rb new file mode 100644 index 00000000000..039c539f9cf --- /dev/null +++ b/app/components/avo/fields/nested_field/show_component.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class Avo::Fields::NestedField::ShowComponent < Avo::Fields::ShowComponent +end diff --git a/app/components/avo/fields/select_record_field/edit_component.rb b/app/components/avo/fields/select_record_field/edit_component.rb new file mode 100644 index 00000000000..237b0479e3b --- /dev/null +++ b/app/components/avo/fields/select_record_field/edit_component.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class Avo::Fields::SelectRecordField::EditComponent < Avo::Fields::BelongsToField::EditComponent +end diff --git a/app/controllers/admin/admin_controller.rb b/app/controllers/admin/admin_controller.rb new file mode 100644 index 00000000000..cabdc558107 --- /dev/null +++ b/app/controllers/admin/admin_controller.rb @@ -0,0 +1,7 @@ +class Admin::AdminController < ApplicationController + include GitHubOAuthable + + def logout + admin_logout + end +end diff --git a/app/controllers/adoptions_controller.rb b/app/controllers/adoptions_controller.rb new file mode 100644 index 00000000000..8ee0d1cbdf1 --- /dev/null +++ b/app/controllers/adoptions_controller.rb @@ -0,0 +1,19 @@ +class AdoptionsController < ApplicationController + include SessionVerifiable + + before_action :find_rubygem + before_action :redirect_to_verify, if: -> { policy(@rubygem).manage_adoption? && !verified_session_active? } + + def index + @ownership_call = @rubygem.ownership_call + @user_request = @rubygem.ownership_requests.find_by(user: current_user) + @ownership_requests = @rubygem.ownership_requests.preload(:user) + end + + private + + def find_rubygem + super + authorize @rubygem, :show_adoption? if @rubygem + end +end diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index a931c00c77c..3eefc10e801 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -1,53 +1,101 @@ class Api::BaseController < ApplicationController skip_before_action :verify_authenticity_token + after_action :skip_session + + rescue_from(Pundit::NotAuthorizedError) do |e| + render_forbidden(e.policy.error) + end private + def name_params + params.permit(:gem_name, :rubygem_name) + end + def gem_name - params[:gem_name] || params[:rubygem_name] + name_params[:gem_name] || name_params[:rubygem_name] end def find_rubygem_by_name @rubygem = Rubygem.find_by name: gem_name return if @rubygem - render plain: "This gem could not be found", status: :not_found - end - - def enqueue_web_hook_jobs(version) - jobs = version.rubygem.web_hooks + WebHook.global - jobs.each do |job| - job.fire( - request.protocol.delete("://"), - request.host_with_port, - version.rubygem, - version - ) - end + render plain: t(:api_gem_not_found), status: :not_found + end + + def verify_api_key_gem_scope + return unless @api_key.rubygem && @api_key.rubygem != @rubygem + + render_forbidden t(:api_key_insufficient_scope) end def verify_with_otp otp = request.headers["HTTP_OTP"] - return if @api_key.user.mfa_api_authorized?(otp) + return if @api_key.mfa_authorized?(otp) prompt_text = otp.present? ? t(:otp_incorrect) : t(:otp_missing) render plain: prompt_text, status: :unauthorized end + def response_with_mfa_warning(message) + if @api_key&.mfa_recommended_not_yet_enabled? + +message << "\n\n" << t("multifactor_auths.api.mfa_recommended_not_yet_enabled").chomp + elsif @api_key&.mfa_recommended_weak_level_enabled? + +message << "\n\n" << t("multifactor_auths.api.mfa_recommended_weak_level_enabled").chomp + else + message + end + end + def authenticate_with_api_key params_key = request.headers["Authorization"] || "" hashed_key = Digest::SHA256.hexdigest(params_key) - @api_key = ApiKey.find_by_hashed_key(hashed_key) - render_unauthorized unless @api_key + @api_key = ApiKey.unexpired.find_by_hashed_key(hashed_key) + return render_unauthorized unless @api_key + set_tags "gemcutter.api_key.owner" => @api_key.owner.to_gid, "gemcutter.user.api_key_id" => @api_key.id + Current.user = @api_key.user + render_forbidden(t(:api_key_soft_deleted)) if @api_key.soft_deleted? + end + + def pundit_user + @api_key + end + + def policy_scope(scope) + super(Array.wrap(scope).prepend(:api)) + end + + def authorize(record, query = nil) + return if record.nil? # not found is handled by the action + super(Array.wrap(record).prepend(:api), query) + end + + def verify_user_api_key + render_forbidden(t(:api_key_forbidden)) if @api_key.user.blank? end def render_unauthorized render plain: t(:please_sign_up), status: :unauthorized end - def render_api_key_forbidden + def render_forbidden(error = nil) + error = error.message if error.respond_to?(:message) + error ||= t(:api_key_forbidden) respond_to do |format| - format.any(:all) { render plain: t(:api_key_forbidden), status: :forbidden } - format.json { render json: { error: t(:api_key_forbidden) }, status: :forbidden } - format.yaml { render yaml: { error: t(:api_key_forbidden) }, status: :forbidden } + format.any(:all) { render plain: error, status: :forbidden } + format.json { render json: { error: }, status: :forbidden } + format.yaml { render yaml: { error: }, status: :forbidden } end end + + def skip_session + request.session_options[:skip] = true + end + + def render_bad_request(error = "bad request") + error = error.message if error.is_a?(Exception) + render json: { error: error.to_s }, status: :bad_request + end + + def owner? + @api_key.owner.owns_gem?(@rubygem) + end end diff --git a/app/controllers/api/compact_index_controller.rb b/app/controllers/api/compact_index_controller.rb index ae1a7151d0e..bdae9f274cd 100644 --- a/app/controllers/api/compact_index_controller.rb +++ b/app/controllers/api/compact_index_controller.rb @@ -1,14 +1,15 @@ class Api::CompactIndexController < Api::BaseController before_action :find_rubygem_by_name, only: [:info] - before_action :cache_expiry_headers def names + cache_expiry_headers names = GemInfo.ordered_names render_range CompactIndex.names(names) end def versions set_surrogate_key "versions" + cache_expiry_headers versions_path = Rails.application.config.rubygems["versions_file_location"] versions_file = CompactIndex::VersionsFile.new(versions_path) from_date = versions_file.updated_at @@ -18,6 +19,7 @@ def versions def info set_surrogate_key "info/* gem/#{@rubygem.name} info/#{@rubygem.name}" + cache_expiry_headers return unless stale?(@rubygem) info_params = GemInfo.new(@rubygem.name).compact_index_info render_range CompactIndex.info(info_params) @@ -25,13 +27,13 @@ def info private - def cache_expiry_headers(fastly_expiry: 3600) - expires_in 60, public: true - fastly_expires_in fastly_expiry - end - def render_range(response_body) - headers["ETag"] = '"' << Digest::MD5.hexdigest(response_body) << '"' + headers["ETag"] = %("#{Digest::MD5.hexdigest(response_body)}") + digest = Digest::SHA256.base64digest(response_body) + headers["Digest"] = "sha-256=#{digest}" + headers["Repr-Digest"] = "sha-256=:#{digest}:" + headers["Accept-Ranges"] = "bytes" + headers["Content-Type"] = "text/plain; charset=utf-8" ranges = Rack::Utils.byte_ranges(request.env, response_body.bytesize) if ranges diff --git a/app/controllers/api/deprecated_controller.rb b/app/controllers/api/deprecated_controller.rb index b29a5277bd2..e9129bdd3ad 100644 --- a/app/controllers/api/deprecated_controller.rb +++ b/app/controllers/api/deprecated_controller.rb @@ -1,6 +1,6 @@ class Api::DeprecatedController < Api::BaseController def index render status: :forbidden, plain: "This version of the Gemcutter plugin has been deprecated." \ - "Please install the latest version using: gem update gemcutter" + "Please install the latest version using: gem update gemcutter" end end diff --git a/app/controllers/api/v1/activities_controller.rb b/app/controllers/api/v1/activities_controller.rb index 6a7f7cf967d..891e68971b0 100644 --- a/app/controllers/api/v1/activities_controller.rb +++ b/app/controllers/api/v1/activities_controller.rb @@ -16,6 +16,9 @@ def render_rubygems(versions) version.rubygem.payload(version) end + set_surrogate_key "api/v1/activities" + cache_expiry_headers + respond_to do |format| format.json { render json: rubygems } format.yaml { render yaml: rubygems } diff --git a/app/controllers/api/v1/api_keys_controller.rb b/app/controllers/api/v1/api_keys_controller.rb index 24c1517219c..a89511f998f 100644 --- a/app/controllers/api/v1/api_keys_controller.rb +++ b/app/controllers/api/v1/api_keys_controller.rb @@ -20,7 +20,8 @@ def create check_mfa(user) do key = generate_unique_rubygems_key - api_key = user.api_keys.build(api_key_create_params.merge(hashed_key: hashed_key(key))) + build_params = { owner: user, hashed_key: hashed_key(key), **api_key_create_params } + api_key = ApiKey.new(build_params) save_and_respond(api_key, key) end @@ -32,9 +33,9 @@ def update user = User.authenticate(username, password) check_mfa(user) do - api_key = user.api_keys.find_by!(hashed_key: hashed_key(params[:api_key])) + api_key = user.api_keys.find_by!(hashed_key: hashed_key(key_param)) - if api_key.update(api_key_update_params) + if api_key.update(api_key_update_params(api_key)) respond_with "Scopes for the API key #{api_key.name} updated" else errors = api_key.errors.full_messages @@ -48,7 +49,13 @@ def update def check_mfa(user) if user&.mfa_gem_signin_authorized?(otp) - yield + if user.mfa_required_not_yet_enabled? + render_forbidden t("multifactor_auths.api.mfa_required_not_yet_enabled").chomp + elsif user.mfa_required_weak_level_enabled? + render_forbidden t("multifactor_auths.api.mfa_required_weak_level_enabled").chomp + else + yield + end elsif user&.mfa_enabled? prompt_text = otp.present? ? t(:otp_incorrect) : t(:otp_missing) render plain: prompt_text, status: :unauthorized @@ -58,8 +65,8 @@ def check_mfa(user) end def save_and_respond(api_key, key) - if api_key.save - Mailer.delay.api_key_created(api_key.id) + if api_key.errors.blank? && api_key.save + Mailer.api_key_created(api_key.id).deliver_later respond_with key else respond_with api_key.errors.full_messages.to_sentence, status: :unprocessable_entity @@ -78,11 +85,15 @@ def otp request.headers["HTTP_OTP"] end + def key_param + params.permit(:api_key).require(:api_key) + end + def api_key_create_params - params.permit(:name, *ApiKey::API_SCOPES) + ApiKeysHelper.api_key_params(params.permit(:name, *ApiKey::API_SCOPES, :mfa, :rubygem_name, :expires_at, scopes: [ApiKey::API_SCOPES])) end - def api_key_update_params - params.permit(*ApiKey::API_SCOPES) + def api_key_update_params(key) + ApiKeysHelper.api_key_params(params.permit(*ApiKey::API_SCOPES, :mfa, scopes: [ApiKey::API_SCOPES]), key) end end diff --git a/app/controllers/api/v1/deletions_controller.rb b/app/controllers/api/v1/deletions_controller.rb index be8955ee14e..bb06d5acae3 100644 --- a/app/controllers/api/v1/deletions_controller.rb +++ b/app/controllers/api/v1/deletions_controller.rb @@ -1,19 +1,27 @@ class Api::V1::DeletionsController < Api::BaseController before_action :authenticate_with_api_key + before_action :verify_user_api_key before_action :find_rubygem_by_name + before_action :verify_api_key_gem_scope before_action :validate_gem_and_version before_action :verify_with_otp - before_action :render_api_key_forbidden, if: :api_key_unauthorized? def create + authorize @rubygem, :yank? # TODO: change to @version @deletion = @api_key.user.deletions.build(version: @version) if @deletion.save StatsD.increment "yank.success" - enqueue_web_hook_jobs(@version) - render plain: "Successfully deleted gem: #{@version.to_title}" + render plain: response_with_mfa_warning("Successfully deleted gem: #{@version.to_title}") + elsif @deletion.ineligible? + StatsD.increment "yank.forbidden" + @deletion.record_yank_forbidden_event! + contact = "Please contact RubyGems support (support@rubygems.org) to request deletion of this version " \ + "if it represents a legal or security risk." + message = "#{@deletion.ineligible_reason} #{contact}" + render plain: response_with_mfa_warning(message), status: :forbidden else StatsD.increment "yank.failure" - render plain: @deletion.errors.full_messages.to_sentence, + render plain: response_with_mfa_warning(@deletion.errors.full_messages.to_sentence), status: :unprocessable_entity end end @@ -22,27 +30,19 @@ def create def validate_gem_and_version if !@rubygem.hosted? - render plain: t(:this_rubygem_could_not_be_found), + render plain: response_with_mfa_warning(t(:this_rubygem_could_not_be_found)), status: :not_found elsif !@rubygem.owned_by?(@api_key.user) - render plain: "You do not have permission to delete this gem.", - status: :forbidden + render_forbidden response_with_mfa_warning("You do not have permission to delete this gem.") else begin - slug = if params[:platform].blank? - params[:version] - else - "#{params[:version]}-#{params[:platform]}" - end - @version = Version.find_from_slug!(@rubygem, slug) + version = params.permit(:version).require(:version) + platform = params.permit(:platform).fetch(:platform, nil) + @version = @rubygem.find_version!(number: version, platform: platform) rescue ActiveRecord::RecordNotFound - render plain: "The version #{params[:version]} does not exist.", + render plain: response_with_mfa_warning("The version #{version}#{" (#{platform})" if platform.present?} does not exist."), status: :not_found end end end - - def api_key_unauthorized? - !@api_key.can_yank_rubygem? - end end diff --git a/app/controllers/api/v1/dependencies_controller.rb b/app/controllers/api/v1/dependencies_controller.rb index ce6dec2fb8b..49c3c7fc299 100644 --- a/app/controllers/api/v1/dependencies_controller.rb +++ b/app/controllers/api/v1/dependencies_controller.rb @@ -1,39 +1,12 @@ class Api::V1::DependenciesController < Api::BaseController - before_action :check_gem_count - GEM_REQUEST_LIMIT = 275 - def index - deps = GemDependent.new(gem_names).to_a - - expires_in 30, public: true - fastly_expires_in 60 - set_surrogate_key("dependencyapi", gem_names.map { |name| "gem/#{name}" }) + cache_expiry_headers(expiry: 30, fastly_expiry: 60) + set_surrogate_key("dependencyapi") respond_to do |format| - format.json { render json: deps } - format.marshal { render plain: Marshal.dump(deps) } + error = "The dependency API has gone away. See https://blog.rubygems.org/2023/02/22/dependency-api-deprecation.html for more information" + format.marshal { render plain: error, status: :not_found } + format.json { render json: { error: error, code: 404 }, status: :not_found } end end - - private - - def check_gem_count - return render plain: "" if gem_names.empty? - return if gem_names.size <= GEM_REQUEST_LIMIT - - case request.format.symbol - when :marshal - render plain: "Too many gems! (use --full-index instead)", status: :unprocessable_entity - when :json - render json: { error: "Too many gems! (use --full-index instead)", code: 422 }, status: :unprocessable_entity - end - end - - def gem_names - @gem_names ||= gems_params[:gems].blank? ? [] : gems_params[:gems].split(",".freeze) - end - - def gems_params - params.permit(:gems) - end end diff --git a/app/controllers/api/v1/downloads_controller.rb b/app/controllers/api/v1/downloads_controller.rb index 1c63a3259a6..278acfd9736 100644 --- a/app/controllers/api/v1/downloads_controller.rb +++ b/app/controllers/api/v1/downloads_controller.rb @@ -28,10 +28,10 @@ def top def all gems = GemDownload.most_downloaded_gems.limit(50) - gems = gems.map do |gem| + gems = gems.filter_map do |gem| next unless gem.version [gem.version.attributes, gem.count] - end.compact + end respond_with_data(gems: gems) end diff --git a/app/controllers/api/v1/github_secret_scanning_controller.rb b/app/controllers/api/v1/github_secret_scanning_controller.rb new file mode 100644 index 00000000000..bdbd1186165 --- /dev/null +++ b/app/controllers/api/v1/github_secret_scanning_controller.rb @@ -0,0 +1,60 @@ +class Api::V1::GitHubSecretScanningController < Api::BaseController + include ApiKeyable + + # API called by GitHub Secret Scanning tool + # see docs https://docs.github.com/en/developers/overview/secret-scanning + # Sample message: + # + # POST / HTTP/1.1 + # Host: HOST + # Accept: */* + # content-type: application/json + # GITHUB-PUBLIC-KEY-IDENTIFIER: 90a421169f0a406205f1563a953312f0be898d3c7b6c06b681aa86a874555f4a + # GITHUB-PUBLIC-KEY-SIGNATURE: MEUCICxTWEpKo7BorLKutFZDS6ie+YFg6ecU7kEA6rUUSJqsAiEA9bK0Iy6vk2QpZOOg2IpBhZ3JRVdwXx1zmgmNAR7Izpc= + # Content-Length: 0000 + # + # [{"token": "some_token", "type": "some_type", "url": "some_url"}] + # + def revoke + key_id = request.headers.fetch("GITHUB-PUBLIC-KEY-IDENTIFIER", "") + signature = request.headers.fetch("GITHUB-PUBLIC-KEY-SIGNATURE", "") + + return render plain: "Missing GitHub Signature", status: :unauthorized if key_id.blank? || signature.blank? + key = secret_scanning_key(key_id) + return render plain: "Can't fetch public key from GitHub", status: :unauthorized if key.empty_public_key? + return render plain: "Invalid GitHub Signature", status: :unauthorized unless key.valid_github_signature?(signature, request.body.read.chomp) + + tokens = params.permit(_json: %i[token type url]).require(:_json).index_by { |t| hashed_key(t.require(:token)) } + api_keys = ApiKey.where(hashed_key: tokens.keys).index_by(&:hashed_key) + resp = tokens.map do |hashed_key, t| + api_key = api_keys[hashed_key] + label = if api_key&.expire! + schedule_revoke_email(api_key, t[:url]) + "true_positive" + else + "false_positive" + end + + { + token_raw: t[:token], + token_type: t[:type], + label: label + } + end + + respond_to do |format| + format.json { render json: resp } + end + end + + private + + def schedule_revoke_email(api_key, url) + return unless api_key.user? + Mailer.api_key_revoked(api_key.owner_id, api_key.name, api_key.scopes.join(", "), url).deliver_later + end + + def secret_scanning_key(key_id) + GitHubSecretScanning.new(key_id) + end +end diff --git a/app/controllers/api/v1/hook_relay_controller.rb b/app/controllers/api/v1/hook_relay_controller.rb new file mode 100644 index 00000000000..07699d29b1e --- /dev/null +++ b/app/controllers/api/v1/hook_relay_controller.rb @@ -0,0 +1,30 @@ +class Api::V1::HookRelayController < Api::BaseController + before_action :authenticate_hook_relay_report + + rescue_from ActiveSupport::SecureCompareRotator::InvalidMatch, with: :render_not_found + + def report + HookRelayReportJob.perform_later(hook_relay_report_params) + + respond_to do |format| + format.json { render json: {} } + end + end + + private + + def hook_relay_report_params + params.permit( + :attempts, :id, :max_attempts, + :status, :stream, :failure_reason, :completed_at, + :created_at, request: [:target_url] + ) + end + + def authenticate_hook_relay_report + account_id, hook_id = params.permit(%i[account_id hook_id]).require(%i[account_id hook_id]) + + ActiveSupport::SecureCompareRotator.new(ENV.fetch("HOOK_RELAY_ACCOUNT_ID", "")).secure_compare!(account_id) + ActiveSupport::SecureCompareRotator.new(ENV.fetch("HOOK_RELAY_HOOK_ID", "")).secure_compare!(hook_id) + end +end diff --git a/app/controllers/api/v1/oidc/api_key_roles_controller.rb b/app/controllers/api/v1/oidc/api_key_roles_controller.rb new file mode 100644 index 00000000000..d3a9ae30cca --- /dev/null +++ b/app/controllers/api/v1/oidc/api_key_roles_controller.rb @@ -0,0 +1,87 @@ +class Api::V1::OIDC::ApiKeyRolesController < Api::BaseController + include ApiKeyable + + before_action :authenticate_with_api_key, except: :assume_role + before_action :verify_user_api_key, except: :assume_role + + with_options only: :assume_role do + before_action :set_api_key_role + before_action :decode_jwt + before_action :verify_jwt + before_action :verify_access + end + + class UnverifiedJWT < StandardError + end + + rescue_from( + UnverifiedJWT, + JSON::JWT::VerificationFailed, JSON::JWK::Set::KidNotFound, + OIDC::AccessPolicy::AccessError, + with: :render_not_found + ) + + rescue_from ActiveRecord::RecordInvalid do |err| + render json: { + errors: err.record.errors + }, status: :unprocessable_entity + end + + def index + render json: @api_key.user.oidc_api_key_roles + end + + def show + render json: @api_key.user.oidc_api_key_roles.find_by!(token: params.permit(:token).require(:token)) + end + + def assume_role + key = nil + api_key = nil + ApiKey.transaction do + key = generate_unique_rubygems_key + api_key = @api_key_role.user.api_keys.create!( + hashed_key: hashed_key(key), + name: "#{@api_key_role.name}-#{@jwt[:jti]}", + **@api_key_role.api_key_permissions.create_params(@api_key_role.user) + ) + OIDC::IdToken.create!( + api_key:, + jwt: { claims: @jwt, header: @jwt.header }, + api_key_role: @api_key_role, + provider: @api_key_role.provider + ) + Mailer.api_key_created(api_key.id).deliver_later + end + + render json: { + rubygems_api_key: key, + name: api_key.name, + scopes: api_key.scopes, + gem: api_key.rubygem, + expires_at: api_key.expires_at + }.compact, status: :created + end + + private + + def set_api_key_role + @api_key_role = OIDC::ApiKeyRole.active.find_by!(token: params.permit(:token).require(:token)) + end + + def decode_jwt + raise UnverifiedJWT, "Provider missing JWKS" if @api_key_role.provider.jwks.blank? + @jwt = JSON::JWT.decode_compact_serialized(params.permit(:jwt).require(:jwt), @api_key_role.provider.jwks) + rescue JSON::ParserError + raise UnverifiedJWT, "Invalid JSON" + end + + def verify_jwt + raise UnverifiedJWT, "Issuer mismatch" unless @api_key_role.provider.issuer == @jwt["iss"] + raise UnverifiedJWT, "Invalid time" unless (@jwt["nbf"]..@jwt["exp"]).cover?(Time.now.to_i) + end + + def verify_access + @api_key_role.access_policy.verify_access!(@jwt) + end +end diff --git a/app/controllers/api/v1/oidc/id_tokens_controller.rb b/app/controllers/api/v1/oidc/id_tokens_controller.rb new file mode 100644 index 00000000000..065a9ff8cb7 --- /dev/null +++ b/app/controllers/api/v1/oidc/id_tokens_controller.rb @@ -0,0 +1,12 @@ +class Api::V1::OIDC::IdTokensController < Api::BaseController + before_action :authenticate_with_api_key + before_action :verify_user_api_key + + def index + render json: @api_key.user.oidc_id_tokens + end + + def show + render json: @api_key.user.oidc_id_tokens.find(params.permit(:id).require(:id)) + end +end diff --git a/app/controllers/api/v1/oidc/providers_controller.rb b/app/controllers/api/v1/oidc/providers_controller.rb new file mode 100644 index 00000000000..aeef5c29e5b --- /dev/null +++ b/app/controllers/api/v1/oidc/providers_controller.rb @@ -0,0 +1,12 @@ +class Api::V1::OIDC::ProvidersController < Api::BaseController + before_action :authenticate_with_api_key + before_action :verify_user_api_key + + def index + render json: OIDC::Provider.all + end + + def show + render json: OIDC::Provider.find(params.permit(:id).require(:id)) + end +end diff --git a/app/controllers/api/v1/oidc/rubygem_trusted_publishers_controller.rb b/app/controllers/api/v1/oidc/rubygem_trusted_publishers_controller.rb new file mode 100644 index 00000000000..e600e3c4ab9 --- /dev/null +++ b/app/controllers/api/v1/oidc/rubygem_trusted_publishers_controller.rb @@ -0,0 +1,63 @@ +class Api::V1::OIDC::RubygemTrustedPublishersController < Api::BaseController + before_action :authenticate_with_api_key + before_action :verify_user_api_key + + before_action :find_rubygem + + before_action :verify_with_otp + + before_action :find_rubygem_trusted_publisher, except: %i[index create] + before_action :set_trusted_publisher_type, only: %i[create] + + def index + render json: @rubygem.oidc_rubygem_trusted_publishers.strict_loading.includes(:trusted_publisher) + end + + def show + render json: @rubygem_trusted_publisher + end + + def create + trusted_publisher = @rubygem.oidc_rubygem_trusted_publishers.build(create_params) + + if trusted_publisher.save + render json: trusted_publisher, status: :created + else + render json: { errors: trusted_publisher.errors, status: :unprocessable_entity }, status: :unprocessable_entity + end + end + + def destroy + @rubygem_trusted_publisher.destroy! + end + + private + + def find_rubygem + super + authorize @rubygem, :configure_trusted_publishers? + end + + def find_rubygem_trusted_publisher + @rubygem_trusted_publisher = @rubygem.oidc_rubygem_trusted_publishers.find(params.permit(:id).require(:id)) + end + + def set_trusted_publisher_type + trusted_publisher_type = params.permit(:trusted_publisher_type).require(:trusted_publisher_type) + + @trusted_publisher_type = OIDC::TrustedPublisher.all.find { |type| type.polymorphic_name == trusted_publisher_type } + + return if @trusted_publisher_type + + render json: { error: t("oidc.trusted_publisher.unsupported_type") }, status: :unprocessable_entity + end + + def create_params + create_params = params.permit( + :trusted_publisher_type, + trusted_publisher: @trusted_publisher_type.permitted_attributes + ) + create_params[:trusted_publisher_attributes] = create_params.delete(:trusted_publisher) + create_params + end +end diff --git a/app/controllers/api/v1/oidc/trusted_publisher_controller.rb b/app/controllers/api/v1/oidc/trusted_publisher_controller.rb new file mode 100644 index 00000000000..3da6fe8d22e --- /dev/null +++ b/app/controllers/api/v1/oidc/trusted_publisher_controller.rb @@ -0,0 +1,81 @@ +class Api::V1::OIDC::TrustedPublisherController < Api::BaseController + include ApiKeyable + + before_action :decode_jwt + before_action :validate_jwt_format + before_action :find_provider + before_action :verify_signature + before_action :find_trusted_publisher + before_action :validate_claims + + class UnsupportedIssuer < StandardError; end + class UnverifiedJWT < StandardError; end + class InvalidJWT < StandardError; end + + rescue_from InvalidJWT, with: :render_bad_request + + rescue_from( + UnsupportedIssuer, UnverifiedJWT, + JSON::JWT::VerificationFailed, JSON::JWK::Set::KidNotFound, + OIDC::AccessPolicy::AccessError, + with: :render_not_found + ) + + def exchange_token + key = generate_unique_rubygems_key + iat = Time.at(@jwt[:iat].to_i, in: "UTC") + api_key = @trusted_publisher.api_keys.create!( + hashed_key: hashed_key(key), + name: "#{@trusted_publisher.name} #{iat.iso8601}", + scopes: %i[push_rubygem], + expires_at: 15.minutes.from_now + ) + + render json: { + rubygems_api_key: key, + name: api_key.name, + scopes: api_key.scopes, + gem: api_key.rubygem, + expires_at: api_key.expires_at + }.compact, status: :created + end + + private + + def decode_jwt + @jwt = JSON::JWT.decode_compact_serialized(params.permit(:jwt).require(:jwt), :skip_verification) + rescue JSON::JWT::InvalidFormat, JSON::ParserError, ArgumentError => e + # invalid base64 raises ArgumentError + render_bad_request(e) + end + + def validate_jwt_format + %w[nbf iat exp].each do |claim| + raise InvalidJWT, "Missing/invalid #{claim}" unless @jwt[claim].is_a?(Integer) + end + %w[iss jti].each do |claim| + raise InvalidJWT, "Missing/invalid #{claim}" unless @jwt[claim].is_a?(String) + end + end + + def find_provider + @provider = OIDC::Provider.find_by!(issuer: @jwt[:iss]) + end + + def verify_signature + raise UnsupportedIssuer, "Provider is missing jwks" if @provider.jwks.blank? + raise UnverifiedJWT, "Invalid time" unless (@jwt["nbf"]..@jwt["exp"]).cover?(Time.now.to_i) + @jwt.verify!(@provider.jwks) + end + + def find_trusted_publisher + unless (trusted_publisher_class = @provider.trusted_publisher_class) + raise UnsupportedIssuer, "Unsuported issuer for trusted publishing" + end + @trusted_publisher = trusted_publisher_class.for_claims(@jwt) + end + + def validate_claims + @trusted_publisher.to_access_policy(@jwt).verify_access!(@jwt) + end +end diff --git a/app/controllers/api/v1/owners_controller.rb b/app/controllers/api/v1/owners_controller.rb index 7e0340cf5c1..ec4e6ed3f1c 100644 --- a/app/controllers/api/v1/owners_controller.rb +++ b/app/controllers/api/v1/owners_controller.rb @@ -1,8 +1,9 @@ class Api::V1::OwnersController < Api::BaseController before_action :authenticate_with_api_key, except: %i[show gems] - before_action :find_rubygem, except: :gems - before_action :verify_gem_ownership, except: %i[show gems] before_action :verify_with_otp, except: %i[show gems] + before_action :find_rubygem, except: :gems + + rescue_from ActiveRecord::RecordNotFound, with: :render_not_found def show respond_to do |format| @@ -12,57 +13,74 @@ def show end def create - return render_api_key_forbidden unless @api_key.can_add_owner? + authorize @rubygem, :add_owner? + owner = User.find_by_name!(email_param) + ownership = @rubygem.ownerships.new(user: owner, authorizer: @api_key.user, **ownership_params) - owner = User.find_by_name(params[:email]) - if owner - ownership = @rubygem.ownerships.new(user: owner, authorizer: @api_key.user) - if ownership.save - OwnersMailer.delay.ownership_confirmation(ownership.id) - render plain: "#{owner.display_handle} was added as an unconfirmed owner. "\ - "Ownership access will be enabled after the user clicks on the confirmation mail sent to their email." - else - render plain: ownership.errors.full_messages.to_sentence, status: :unprocessable_entity - end + if ownership.save + OwnersMailer.ownership_confirmation(ownership).deliver_later + render plain: response_with_mfa_warning("#{owner.display_handle} was added as an unconfirmed owner. " \ + "Ownership access will be enabled after the user clicks on the " \ + "confirmation mail sent to their email.") else - render plain: "Owner could not be found.", status: :not_found + render plain: response_with_mfa_warning(ownership.errors.full_messages.to_sentence), status: :unprocessable_entity + end + end + + def update + owner = User.find_by_name(email_param) + ownership = @rubygem.ownerships.find_by(user: owner) if owner + if ownership + authorize(ownership) + else + authorize(@rubygem, :update_owner?) # don't leak presence of an email unless authorized + return render_not_found + end + + if ownership.update(ownership_params) + render plain: response_with_mfa_warning("Owner updated successfully.") + else + render plain: response_with_mfa_warning(ownership.errors.full_messages.to_sentence), status: :unprocessable_entity end end def destroy - return render_api_key_forbidden unless @api_key.can_remove_owner? + authorize @rubygem, :remove_owner? + owner = User.find_by_name!(email_param) + ownership = @rubygem.ownerships_including_unconfirmed.find_by!(user: owner) - owner = @rubygem.owners_including_unconfirmed.find_by_name(params[:email]) - if owner - ownership = @rubygem.ownerships_including_unconfirmed.find_by(user_id: owner.id) - if ownership.safe_destroy - OwnersMailer.delay.owner_removed(ownership.user_id, @api_key.user.id, ownership.rubygem_id) - render plain: "Owner removed successfully." - else - render plain: "Unable to remove owner.", status: :forbidden - end + if ownership.safe_destroy + OwnersMailer.owner_removed(ownership.user_id, @api_key.user.id, ownership.rubygem_id).deliver_later + render plain: response_with_mfa_warning("Owner removed successfully.") else - render plain: "Owner could not be found.", status: :not_found + render plain: response_with_mfa_warning("Unable to remove owner."), status: :forbidden end end def gems - user = User.find_by_slug(params[:handle]) - if user - rubygems = user.rubygems.with_versions - respond_to do |format| - format.json { render json: rubygems } - format.yaml { render yaml: rubygems } - end - else - render plain: "Owner could not be found.", status: :not_found + owner = User.find_by_slug!(params[:handle]) + rubygems = owner.rubygems.with_versions.preload( + :linkset, :gem_download, + most_recent_version: { dependencies: :rubygem, gem_download: nil } + ).strict_loading + + respond_to do |format| + format.json { render json: rubygems } + format.yaml { render yaml: rubygems } end end protected - def verify_gem_ownership - return if @api_key.user.rubygems.find_by_name(params[:rubygem_id]) - render plain: "You do not have permission to manage this gem.", status: :unauthorized + def render_not_found + render plain: response_with_mfa_warning("Owner could not be found."), status: :not_found + end + + def email_param + params.permit(:email).require(:email) + end + + def ownership_params + params.permit(:role) end end diff --git a/app/controllers/api/v1/profiles_controller.rb b/app/controllers/api/v1/profiles_controller.rb index e69733d1fd8..3a1670c01d4 100644 --- a/app/controllers/api/v1/profiles_controller.rb +++ b/app/controllers/api/v1/profiles_controller.rb @@ -6,4 +6,21 @@ def show format.yaml { render yaml: @user } end end + + def me + authenticate_or_request_with_http_basic do |username, password| + if (user = User::WithPrivateFields.authenticate(username.strip, password)) + respond_to do |format| + format.json { render json: user } + format.yaml { render yaml: user } + end + else + respond_to do |format| + message = { error: "Invalid credentials", code: 401 } + format.json { render json: message, status: :unauthorized } + format.yaml { render yaml: message, status: :unauthorized } + end + end + end + end end diff --git a/app/controllers/api/v1/rubygems_controller.rb b/app/controllers/api/v1/rubygems_controller.rb index 03cfc099165..04b2b5c8007 100644 --- a/app/controllers/api/v1/rubygems_controller.rb +++ b/app/controllers/api/v1/rubygems_controller.rb @@ -1,14 +1,15 @@ class Api::V1::RubygemsController < Api::BaseController before_action :authenticate_with_api_key, except: %i[show reverse_dependencies] - before_action :find_rubygem, only: %i[show reverse_dependencies] + before_action :verify_user_api_key, except: %i[show reverse_dependencies create] + before_action :find_rubygem, only: %i[show reverse_dependencies] before_action :cors_preflight_check, only: :show before_action :verify_with_otp, only: %i[create] after_action :cors_set_access_control_headers, only: :show def index - return render_forbidden unless @api_key.can_index_rubygems? - + authorize Rubygem, :index? @rubygems = @api_key.user.rubygems.with_versions + .preload(:linkset, :gem_download, most_recent_version: { dependencies: :rubygem, gem_download: nil }) respond_to do |format| format.json { render json: @rubygems } format.yaml { render yaml: @rubygems } @@ -16,7 +17,10 @@ def index end def show - if @rubygem.hosted? && @rubygem.public_versions.indexed.count.nonzero? + cache_expiry_headers + set_surrogate_key "gem/#{@rubygem.name}" + + if @rubygem.hosted? && @rubygem.public_versions.indexed.present? respond_to do |format| format.json { render json: @rubygem } format.yaml { render yaml: @rubygem } @@ -27,27 +31,30 @@ def show end def create - return render_api_key_forbidden unless @api_key.can_push_rubygem? + authorize Rubygem, :create? + return render_forbidden(t(:api_key_insufficient_scope)) unless @api_key.can_push_rubygem? - gemcutter = Pusher.new(@api_key.user, request.body, request.remote_ip) - enqueue_web_hook_jobs(gemcutter.version) if gemcutter.process - render plain: gemcutter.message, status: gemcutter.code - rescue => e - Honeybadger.notify(e) + gemcutter = Pusher.new(@api_key, request.body, request:) + gemcutter.process + render plain: response_with_mfa_warning(gemcutter.message), status: gemcutter.code + rescue Pundit::NotAuthorizedError + raise # allow rescue_from in base_controller to handle this + rescue StandardError => e + Rails.error.report(e, handled: true) render plain: "Server error. Please try again.", status: :internal_server_error end def reverse_dependencies - names = begin - case params[:only] - when "development" - @rubygem.reverse_development_dependencies.pluck(:name) - when "runtime" - @rubygem.reverse_runtime_dependencies.pluck(:name) - else - @rubygem.reverse_dependencies.pluck(:name) - end - end + cache_expiry_headers(fastly_expiry: 30) + + names = case params[:only] + when "development" + @rubygem.reverse_development_dependencies.pluck(:name) + when "runtime" + @rubygem.reverse_runtime_dependencies.pluck(:name) + else + @rubygem.reverse_dependencies.pluck(:name) + end respond_to do |format| format.json { render json: names } diff --git a/app/controllers/api/v1/searches_controller.rb b/app/controllers/api/v1/searches_controller.rb index 1fd76c5860f..4ea23590431 100644 --- a/app/controllers/api/v1/searches_controller.rb +++ b/app/controllers/api/v1/searches_controller.rb @@ -1,22 +1,34 @@ class Api::V1::SearchesController < Api::BaseController - before_action :set_page, only: :show - before_action :verify_query_string, only: :show + before_action :set_page, only: %i[show autocomplete] + before_action :verify_query_string, only: %i[show autocomplete] + + rescue_from ElasticSearcher::SearchNotAvailableError, with: :search_not_available_error + rescue_from ElasticSearcher::InvalidQueryError, with: :render_bad_request def show - @rubygems = ElasticSearcher.new(query_params, api: true, page: @page).search + @rubygems = ElasticSearcher.new(query_params, page: @page).api_search respond_to do |format| format.json { render json: @rubygems } format.yaml { render yaml: @rubygems } end end + def autocomplete + results = ElasticSearcher.new(query_params, page: @page).suggestions + render json: results + end + private def verify_query_string - render plain: "bad request", status: :bad_request unless query_params.is_a?(String) + render_bad_request unless query_params.is_a?(String) + end + + def search_not_available_error(error) + render plain: error.message, status: :service_unavailable end def query_params - params.require(:query) + params.permit(:query).require(:query) end end diff --git a/app/controllers/api/v1/timeframe_versions_controller.rb b/app/controllers/api/v1/timeframe_versions_controller.rb index 4bf067d13f5..31007ea9a31 100644 --- a/app/controllers/api/v1/timeframe_versions_controller.rb +++ b/app/controllers/api/v1/timeframe_versions_controller.rb @@ -1,6 +1,6 @@ class Api::V1::TimeframeVersionsController < Api::BaseController class InvalidTimeframeParameterError < StandardError; end - rescue_from InvalidTimeframeParameterError, with: :bad_request_response + rescue_from InvalidTimeframeParameterError, with: :render_bad_request before_action :set_page, :ensure_valid_timerange, only: :index MAXIMUM_TIMEFRAME_QUERY_IN_DAYS = 7 @@ -13,10 +13,6 @@ def index private - def bad_request_response(exception) - render plain: exception.message, status: :bad_request - end - def ensure_valid_timerange if (to_time - from_time).to_i > MAXIMUM_TIMEFRAME_QUERY_IN_DAYS.days raise InvalidTimeframeParameterError, @@ -28,19 +24,19 @@ def ensure_valid_timerange end def from_time - @from_time ||= Time.iso8601(params.require(:from)) + @from_time ||= Time.iso8601(params.permit(:from).require(:from)) rescue ArgumentError raise InvalidTimeframeParameterError, "the from parameter must be iso8601 formatted" end def to_time @to_time ||= params[:to].blank? ? Time.zone.now : Time.iso8601(params[:to]) - rescue ArgumentError + rescue ArgumentError, TypeError raise InvalidTimeframeParameterError, 'the "to" parameter must be iso8601 formatted' end def render_rubygems(versions) - rubygems = versions.includes(:dependencies, rubygem: :linkset).map do |version| + rubygems = versions.includes(:dependencies, :gem_download, rubygem: %i[linkset gem_download]).map do |version| payload = version.rubygem.payload(version) payload.merge(version.as_json) end diff --git a/app/controllers/api/v1/versions_controller.rb b/app/controllers/api/v1/versions_controller.rb index 55467c4a6ee..a4f3b8dc2b3 100644 --- a/app/controllers/api/v1/versions_controller.rb +++ b/app/controllers/api/v1/versions_controller.rb @@ -4,14 +4,14 @@ class Api::V1::VersionsController < Api::BaseController def show return unless stale?(@rubygem) - expires_in 0, public: true - fastly_expires_in 60 + cache_expiry_headers set_surrogate_key "gem/#{@rubygem.name}" - if @rubygem.public_versions.count.nonzero? + versions = @rubygem.public_versions.includes(:gem_download) + if versions.present? respond_to do |format| - format.json { render json: @rubygem.public_versions } - format.yaml { render yaml: @rubygem.public_versions } + format.json { render json: versions } + format.yaml { render yaml: versions } end else render plain: t(:this_rubygem_could_not_be_found), status: :not_found @@ -20,14 +20,19 @@ def show def latest rubygem = Rubygem.find_by_name(params[:id]) - version = nil - version = rubygem.versions.most_recent if rubygem&.public_versions&.indexed&.count&.nonzero? + cache_expiry_headers + set_surrogate_key "gem/#{params[:id]}" + + version = nil + version = rubygem.most_recent_version if rubygem&.public_versions.present? number = version.number if version render json: { "version" => number || "unknown" }, callback: params["callback"] end def reverse_dependencies + cache_expiry_headers(fastly_expiry: 30) + names = Version.reverse_dependencies(params[:id]).pluck(:full_name) respond_to do |format| format.json { render json: names } diff --git a/app/controllers/api/v1/web_hooks_controller.rb b/app/controllers/api/v1/web_hooks_controller.rb index e9d7c589549..77a66eb1fd6 100644 --- a/app/controllers/api/v1/web_hooks_controller.rb +++ b/app/controllers/api/v1/web_hooks_controller.rb @@ -1,9 +1,10 @@ class Api::V1::WebHooksController < Api::BaseController before_action :authenticate_with_api_key - before_action :render_api_key_forbidden, if: :api_key_unauthorized? + before_action :verify_user_api_key before_action :find_rubygem_by_name, :set_url, except: :index def index + authorize WebHook respond_to do |format| format.json { render json: @api_key.user.all_hooks } format.yaml { render yaml: @api_key.user.all_hooks } @@ -11,7 +12,7 @@ def index end def create - webhook = @api_key.user.web_hooks.build(url: @url, rubygem: @rubygem) + webhook = authorize @api_key.user.web_hooks.build(url: @url, rubygem: @rubygem) if webhook.save render(plain: webhook.success_message, status: :created) else @@ -20,7 +21,7 @@ def create end def remove - webhook = @api_key.user.web_hooks.find_by_rubygem_id_and_url(@rubygem&.id, @url) + webhook = authorize @api_key.user.web_hooks.find_by_rubygem_id_and_url(@rubygem&.id, @url) if webhook&.destroy render(plain: webhook.removed_message) else @@ -32,11 +33,15 @@ def fire webhook = @api_key.user.web_hooks.new(url: @url) @rubygem ||= Rubygem.find_by_name("gemcutter") - if webhook.fire(request.protocol.delete("://"), request.host_with_port, @rubygem, - @rubygem.versions.most_recent, delayed: false) - render plain: webhook.deployed_message(@rubygem) + authorize webhook + + response = webhook.fire(request.protocol.delete("://"), request.host_with_port, + @rubygem.most_recent_version, delayed: false) + + if response.fetch("status") == "success" + render plain: webhook.deployed_message(@rubygem) + hook_relay_message(response) else - render plain: webhook.failed_message(@rubygem), status: :bad_request + render_bad_request webhook.failed_message(@rubygem) + hook_relay_message(response) end end @@ -49,11 +54,24 @@ def find_rubygem_by_name end def set_url - render plain: "URL was not provided", status: :bad_request unless params[:url] + render_bad_request "URL was not provided" unless params[:url] @url = params[:url] end - def api_key_unauthorized? - !@api_key.can_access_webhooks? + def hook_relay_message(response) + status = response.fetch("status") + msg = +"" + msg << "\nFailed with status #{status.inspect}: #{response['failure_reason']}" if status != "success" + if response.key?("responses") && response["responses"].any? + r = response.dig("responses", -1) + msg << "\nError: #{r['error']}" if r["error"] + msg << "\n\nResponse: #{r['code']}" + r.fetch("headers", []).each do |k, v| + msg << "\n#{k}: #{v}" + end + msg << "\n\n#{r['body']}" + end + + msg end end diff --git a/app/controllers/api/v1/webauthn_verifications_controller.rb b/app/controllers/api/v1/webauthn_verifications_controller.rb new file mode 100644 index 00000000000..830098d8f01 --- /dev/null +++ b/app/controllers/api/v1/webauthn_verifications_controller.rb @@ -0,0 +1,50 @@ +# This controller generates a single-use link as part of the Webauthn CLI flow. It does not challenge +# the user with a Webauthn login. That is done in controllers/webauthn_verifications_controller. +class Api::V1::WebauthnVerificationsController < Api::BaseController + before_action :authenticate_with_credentials + before_action :disable_cache, only: :status + + def create + if @user.webauthn_enabled? + verification = @user.refresh_webauthn_verification + webauthn_path = webauthn_verification_url(verification.path_token) + respond_to do |format| + format.any(:all) { render plain: webauthn_path } + format.yaml { render yaml: { path: webauthn_path, expiry: verification.path_token_expires_at.utc } } + format.json { render json: { path: webauthn_path, expiry: verification.path_token_expires_at.utc } } + end + else + render plain: t("settings.edit.no_webauthn_credentials"), status: :unprocessable_entity + end + end + + def status + verification = @user.webauthn_verification + if verification.path_token != params[:webauthn_token] + render json: { status: :not_found, message: t(:not_found) } + elsif verification.otp_expired? + render json: { status: :expired, message: t("webauthn_verifications.expired_or_already_used") } + elsif verification.otp.nil? + render json: { status: :pending, message: t("webauthn_verifications.pending") } + else + render json: { status: :success, code: verification.otp } + end + end + + private + + def authenticate_with_credentials + params_key = request.headers["Authorization"] || "" + hashed_key = Digest::SHA256.hexdigest(params_key) + api_key = ApiKey.unexpired.find_by_hashed_key(hashed_key) + + @user = authenticated_user(api_key) + end + + def authenticated_user(api_key) + return api_key.user if api_key&.user? + authenticate_or_request_with_http_basic do |username, password| + User.authenticate(username.strip, password) + end + end +end diff --git a/app/controllers/api/v2/contents_controller.rb b/app/controllers/api/v2/contents_controller.rb new file mode 100644 index 00000000000..99964b132ec --- /dev/null +++ b/app/controllers/api/v2/contents_controller.rb @@ -0,0 +1,35 @@ +class Api::V2::ContentsController < Api::BaseController + before_action :find_rubygem_by_name, only: [:index] + + def index + return unless stale?(@rubygem) + cache_expiry_headers + set_surrogate_key "gem/#{@rubygem.name}" + + find_version + return unless @version + + checksums_file = @version.manifest.checksums_file + return render plain: "Content is unavailable for this version.", status: :not_found unless checksums_file + + respond_to do |format| + format.json { render json: checksums_payload(checksums_file) } + format.yaml { render yaml: checksums_payload(checksums_file) } + format.sha256 { render plain: checksums_file } + end + end + + protected + + def find_version + version_params = params.permit(:version_number, :platform) + @version = @rubygem.find_public_version(version_params[:version_number], version_params[:platform]) + render plain: "This version could not be found.", status: :not_found unless @version + end + + def checksums_payload(checksums_file) + ShasumFormat.parse(checksums_file).transform_values do |checksum| + { VersionManifest::DEFAULT_DIGEST => checksum } + end + end +end diff --git a/app/controllers/api/v2/versions_controller.rb b/app/controllers/api/v2/versions_controller.rb index 05749951134..19001621fe4 100644 --- a/app/controllers/api/v2/versions_controller.rb +++ b/app/controllers/api/v2/versions_controller.rb @@ -3,8 +3,10 @@ class Api::V2::VersionsController < Api::BaseController def show return unless stale?(@rubygem) + cache_expiry_headers + set_surrogate_key "gem/#{@rubygem.name}" - version = @rubygem.public_version_payload(params[:number], params[:platform]) + version = @rubygem.public_version_payload(version_params[:number], version_params[:platform]) if version respond_to do |format| format.json { render json: version } @@ -14,4 +16,10 @@ def show render plain: "This version could not be found.", status: :not_found end end + + protected + + def version_params + params.permit(:platform, :number) + end end diff --git a/app/controllers/api_keys_controller.rb b/app/controllers/api_keys_controller.rb index 2e3bf47f9ee..65663f0cff9 100644 --- a/app/controllers/api_keys_controller.rb +++ b/app/controllers/api_keys_controller.rb @@ -1,11 +1,15 @@ class ApiKeysController < ApplicationController + before_action :disable_cache, only: :index + before_action :set_page, only: :index + include ApiKeyable - before_action :redirect_to_signin, unless: :signed_in? - before_action :redirect_to_verify, unless: :password_session_active? + + include SessionVerifiable + verify_session_before def index @api_key = session.delete(:api_key) - @api_keys = current_user.api_keys + @api_keys = current_user.api_keys.unexpired.not_oidc.preload(ownership: :rubygem).page(@page) redirect_to new_profile_api_key_path if @api_keys.empty? end @@ -13,40 +17,57 @@ def new @api_key = current_user.api_keys.build end + def edit + @api_key = current_user.api_keys.find(params.permit(:id).require(:id)) + return unless @api_key.soft_deleted? + + flash[:error] = t(".invalid_key") + redirect_to profile_api_keys_path + end + def create key = generate_unique_rubygems_key - @api_key = current_user.api_keys.build(api_key_params.merge(hashed_key: hashed_key(key))) + build_params = { owner: current_user, hashed_key: hashed_key(key), **api_key_create_params } + @api_key = ApiKey.new(build_params) + + if @api_key.errors.present? + flash.now[:error] = @api_key.errors.full_messages.to_sentence + @api_key = current_user.api_keys.build(api_key_create_params.merge(rubygem_id: nil)) + return render :new + end if @api_key.save - Mailer.delay.api_key_created(@api_key.id) + Mailer.api_key_created(@api_key.id).deliver_later session[:api_key] = key redirect_to profile_api_keys_path, flash: { notice: t(".success") } else - flash[:error] = @api_key.errors.full_messages.to_sentence + flash.now[:error] = @api_key.errors.full_messages.to_sentence render :new end end - def edit - @api_key = current_user.api_keys.find(params.require(:id)) - end - def update - @api_key = current_user.api_keys.find(params.require(:id)) + @api_key = current_user.api_keys.find(params.permit(:id).require(:id)) + @api_key.assign_attributes(api_key_update_params(@api_key)) - if @api_key.update(api_key_params) + if @api_key.errors.present? + flash.now[:error] = @api_key.errors.full_messages.to_sentence + return render :edit + end + + if @api_key.save redirect_to profile_api_keys_path, flash: { notice: t(".success") } else - flash[:error] = @api_key.errors.full_messages.to_sentence + flash.now[:error] = @api_key.errors.full_messages.to_sentence render :edit end end def destroy - api_key = current_user.api_keys.find(params.require(:id)) + api_key = current_user.api_keys.find(params.permit(:id).require(:id)) - if api_key.destroy + if api_key.expire! flash[:notice] = t(".success", name: api_key.name) else flash[:error] = api_key.errors.full_messages.to_sentence @@ -55,7 +76,7 @@ def destroy end def reset - if current_user.api_keys.destroy_all + if current_user.api_keys.expire_all! flash[:notice] = t(".success") else flash[:error] = t("try_again") @@ -65,12 +86,26 @@ def reset private - def api_key_params - params.require(:api_key).permit(:name, *ApiKey::API_SCOPES) + def verify_session_redirect_path + case action_name + when "reset", "destroy" + profile_api_keys_path + when "create" + new_profile_api_key_path + when "update" + edit_profile_api_key_path(params.permit(:id).require(:id)) + else + super + end + end + + def api_key_create_params + ApiKeysHelper.api_key_params(params.permit(api_key: [:name, *ApiKey::API_SCOPES, :mfa, :rubygem_id, :expires_at]).require(:api_key)) end - def redirect_to_verify - session[:redirect_uri] = profile_api_keys_path - redirect_to verify_session_path + def api_key_update_params(existing_api_key = nil) + ApiKeysHelper.api_key_params( + params.permit(api_key: [*ApiKey::API_SCOPES, :mfa, :rubygem_id, { scopes: [ApiKey::API_SCOPES] }]).require(:api_key), existing_api_key + ) end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index e204ae7fdaf..d017ae5a988 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,14 +1,42 @@ class ApplicationController < ActionController::Base include Clearance::Authentication - include Clearance::Authorization + include Pundit::Authorization + include ApplicationMultifactorMethods + include TraceTagger helper ActiveSupport::NumberHelper rescue_from ActiveRecord::RecordNotFound, with: :render_not_found rescue_from ActionController::InvalidAuthenticityToken, with: :render_forbidden + rescue_from ActionController::UnpermittedParameters, with: :render_bad_request + rescue_from(Pundit::NotAuthorizedError) do |e| + render_forbidden(e.policy.error) + end before_action :set_locale before_action :reject_null_char_param + before_action :reject_path_params_param + before_action :reject_null_char_cookie + before_action :set_error_context_user + before_action :set_user_tag + before_action :set_current_request + + add_flash_types :notice_html + + ### + # Content security policy override for script-src + # This is necessary because we use a SHA256 for the importmap script tag + # because caching behavior of the mostly static pages could mean longer lived nonces + # being served from cache instead of unique nonces for each request. + # This ensures that importmap passes CSP and can be cached safely. + content_security_policy do |policy| + policy.script_src( + :self, + "'sha256-#{Digest::SHA256.base64digest(Rails.application.importmap.to_json(resolver: ApplicationController.helpers))}'", + "https://secure.gaug.es", + "https://www.fastly-insights.com" + ) + end def set_locale I18n.locale = user_locale @@ -19,25 +47,38 @@ def set_locale I18n.locale = I18n.default_locale end + def set_user_tag + set_tag "gemcutter.user.id", current_user.id if signed_in? + end + rescue_from(ActionController::ParameterMissing) do |e| - render plain: "Request is missing param '#{e.param}'", status: :bad_request + render_bad_request "Request is missing param '#{e.param}'" end - def self.http_basic_authenticate_with(options = {}) + def self.http_basic_authenticate_with(**options) before_action(options.except(:name, :password, :realm)) do - raise "Invalid authentication options" unless http_basic_authentication_options_valid?(options) + raise "Invalid authentication options" unless http_basic_authentication_options_valid?(**options) end super end protected - def http_basic_authentication_options_valid?(options) + def http_basic_authentication_options_valid?(**options) options[:password].present? && options[:name].present? end - def fastly_expires_in(seconds) - response.headers["Surrogate-Control"] = "max-age=#{seconds}" + def cache_expiry_headers(expiry: 60, fastly_expiry: 3600) + expires_in expiry, public: true + fastly_expires_in fastly_expiry + end + + def fastly_expires_in(seconds, stale_while_revalidate: seconds / 2, stale_if_error: seconds / 2) + response.headers["Surrogate-Control"] = { + "max-age" => seconds, + "stale-while-revalidate" => stale_while_revalidate, + "stale-if-error" => stale_if_error + }.compact.map { |k, v| "#{k}=#{v}" }.join(", ") end def set_surrogate_key(*surrogate_keys) @@ -49,6 +90,10 @@ def redirect_to_signin redirect_to sign_in_path, alert: t("please_sign_in") end + def redirect_to_root + redirect_to root_path + end + def find_rubygem @rubygem = Rubygem.find_by_name(params[:rubygem_id] || params[:id]) return if @rubygem @@ -57,7 +102,7 @@ def find_rubygem render plain: t(:this_rubygem_could_not_be_found), status: :not_found end format.html do - render file: Rails.root.join("public", "404.html"), status: :not_found, layout: false, formats: [:html] + render file: Rails.public_path.join("404.html"), status: :not_found, layout: false, formats: [:html] end end end @@ -84,15 +129,21 @@ def http_head_locale def render_not_found respond_to do |format| - format.html { render file: Rails.root.join("public", "404.html"), status: :not_found, layout: false } + format.html { render file: Rails.public_path.join("404.html"), status: :not_found, layout: false } format.json { render json: { error: t(:not_found) }, status: :not_found } format.yaml { render yaml: { error: t(:not_found) }, status: :not_found } - format.any(:all) { render text: t(:not_found), status: :not_found } + format.any(:all) { render plain: t(:not_found), status: :not_found } end end - def render_forbidden - render plain: "forbidden", status: :forbidden + def render_forbidden(error = nil) + error ||= t(:forbidden) + render plain: error, status: :forbidden + end + + def render_bad_request(error = "bad request") + error = error.message if error.is_a?(Exception) + render plain: error.to_s, status: :bad_request end def redirect_to_page_with_error @@ -108,20 +159,49 @@ def valid_page_param?(max_page) end def reject_null_char_param - render plain: "bad request", status: :bad_request if params.to_s.include?("\\u0000") + render_bad_request if params.to_s.include?("\\u0000") + end + + # Fix for https://github.com/kaminari/kaminari/pull/1123, remove after this is merged and in use. + def reject_path_params_param + params.delete(:path_params) + end + + def reject_null_char_cookie + contains_null_char = cookies.map { |cookie| cookie.join("=") }.join(";").include?("\u0000") + render_bad_request if contains_null_char end def sanitize_params params.delete(:params) end - def set_cache_headers + def disable_cache response.headers["Cache-Control"] = "no-cache, no-store" response.headers["Pragma"] = "no-cache" response.headers["Expires"] = "Fri, 01 Jan 1990 00:00:00 GMT" end - def password_session_active? - session[:verification] && session[:verification] > Time.current + # Avoid leaking confirmation token in referrer header on certain pages + def no_referrer + headers["Referrer-Policy"] = "no-referrer" + end + + def set_error_context_user + return unless current_user + + Rails.error.set_context( + user_id: current_user.id, + user_email: current_user.email + ) + end + + def set_current_request + Current.request = request + Current.user = current_user + end + + def browser + Browser.new(request.user_agent) end end diff --git a/app/controllers/avatars_controller.rb b/app/controllers/avatars_controller.rb new file mode 100644 index 00000000000..d0cb24c4f1a --- /dev/null +++ b/app/controllers/avatars_controller.rb @@ -0,0 +1,67 @@ +class AvatarsController < ApplicationController + before_action :find_user + before_action :set_size + before_action :set_theme + + def show + gravatar_url = @user.gravatar_url(size: @size, default: "404", secure: true) + + resp = gravatar_client + .get(gravatar_url, nil, + { "Accept" => "image/png", "Connection" => "close", "User-Agent" => "RubyGems.org avatar proxy" }) + + if resp.success? + fastly_expires_in(5.minutes) + + # don't copy other headers, since they might leak user info + response.headers["last-modified"] = resp.headers["last-modified"] if resp.headers["last-modified"] + filename = "#{@user.display_id}_avatar_#{@size}.#{params[:format]}" + send_data(resp.body, type: resp.headers["content-type"], disposition: "inline", filename:) + elsif resp.status == 404 + fastly_expires_in(5.minutes) + + # means gravatar doesn't have an avatar for this user + # we'll just redirect to our default avatar instead, so everything is cachable + redirect_to default_avatar_url + else + # any other error, just redirect to our default avatar + # this includes 400, 429, 500s, etc + logger.warn(message: "Failed to fetch gravatar", status: resp.status, url: gravatar_url, user_id: @user.id) + redirect_to default_avatar_url + end + end + + private + + def find_user + @user = User.find_by_slug(params[:id]) + return if @user + render_not_found + end + + def set_size + @size = params.permit(:size).fetch(:size, 64).to_i + return unless @size < 1 || @size > 2048 + render plain: "Invalid size", status: :bad_request + end + + def set_theme + @theme = params.permit(:theme).fetch(:theme, "light") + return if %w[light dark].include?(@theme) + render plain: "Invalid theme", status: :bad_request + end + + def default_avatar_url + case @theme + when "light" then "/images/avatar.svg" + when "dark" then "/images/avatar_inverted.svg" + else raise "invalid default avatar theme, only light and dark are suported" + end + end + + def gravatar_client + Faraday.new(nil, request: { timeout: 2 }) do |f| + f.response :logger, logger, headers: false, errors: true + end + end +end diff --git a/app/controllers/avo/admin_github_users_controller.rb b/app/controllers/avo/admin_github_users_controller.rb new file mode 100644 index 00000000000..efee5b017fd --- /dev/null +++ b/app/controllers/avo/admin_github_users_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::AdminGitHubUsersController < Avo::ResourcesController +end diff --git a/app/controllers/avo/api_key_rubygem_scopes_controller.rb b/app/controllers/avo/api_key_rubygem_scopes_controller.rb new file mode 100644 index 00000000000..16fd906b58d --- /dev/null +++ b/app/controllers/avo/api_key_rubygem_scopes_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::ApiKeyRubygemScopesController < Avo::ResourcesController +end diff --git a/app/controllers/avo/api_keys_controller.rb b/app/controllers/avo/api_keys_controller.rb new file mode 100644 index 00000000000..30bc406c1d1 --- /dev/null +++ b/app/controllers/avo/api_keys_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::ApiKeysController < Avo::ResourcesController +end diff --git a/app/controllers/avo/audits_controller.rb b/app/controllers/avo/audits_controller.rb new file mode 100644 index 00000000000..d2d1c4e4c58 --- /dev/null +++ b/app/controllers/avo/audits_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::AuditsController < Avo::ResourcesController +end diff --git a/app/controllers/avo/deletions_controller.rb b/app/controllers/avo/deletions_controller.rb new file mode 100644 index 00000000000..e24a5f6be5c --- /dev/null +++ b/app/controllers/avo/deletions_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::DeletionsController < Avo::ResourcesController +end diff --git a/app/controllers/avo/dependencies_controller.rb b/app/controllers/avo/dependencies_controller.rb new file mode 100644 index 00000000000..c83b6d96e12 --- /dev/null +++ b/app/controllers/avo/dependencies_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::DependenciesController < Avo::ResourcesController +end diff --git a/app/controllers/avo/events_rubygem_events_controller.rb b/app/controllers/avo/events_rubygem_events_controller.rb new file mode 100644 index 00000000000..9ca1620add3 --- /dev/null +++ b/app/controllers/avo/events_rubygem_events_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::EventsRubygemEventsController < Avo::ResourcesController +end diff --git a/app/controllers/avo/events_user_events_controller.rb b/app/controllers/avo/events_user_events_controller.rb new file mode 100644 index 00000000000..ac52089d78d --- /dev/null +++ b/app/controllers/avo/events_user_events_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::EventsUserEventsController < Avo::ResourcesController +end diff --git a/app/controllers/avo/gem_downloads_controller.rb b/app/controllers/avo/gem_downloads_controller.rb new file mode 100644 index 00000000000..4b4dedaf8b6 --- /dev/null +++ b/app/controllers/avo/gem_downloads_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::GemDownloadsController < Avo::ResourcesController +end diff --git a/app/controllers/avo/gem_name_reservations_controller.rb b/app/controllers/avo/gem_name_reservations_controller.rb new file mode 100644 index 00000000000..70e8b67e5d7 --- /dev/null +++ b/app/controllers/avo/gem_name_reservations_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::GemNameReservationsController < Avo::ResourcesController +end diff --git a/app/controllers/avo/gem_typo_exceptions_controller.rb b/app/controllers/avo/gem_typo_exceptions_controller.rb new file mode 100644 index 00000000000..ec6fe6dfaf8 --- /dev/null +++ b/app/controllers/avo/gem_typo_exceptions_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::GemTypoExceptionsController < Avo::ResourcesController +end diff --git a/app/controllers/avo/geoip_infos_controller.rb b/app/controllers/avo/geoip_infos_controller.rb new file mode 100644 index 00000000000..2e10383e8f8 --- /dev/null +++ b/app/controllers/avo/geoip_infos_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::GeoipInfosController < Avo::ResourcesController +end diff --git a/app/controllers/avo/ip_addresses_controller.rb b/app/controllers/avo/ip_addresses_controller.rb new file mode 100644 index 00000000000..dc690b57d15 --- /dev/null +++ b/app/controllers/avo/ip_addresses_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::IpAddressesController < Avo::ResourcesController +end diff --git a/app/controllers/avo/link_verifications_controller.rb b/app/controllers/avo/link_verifications_controller.rb new file mode 100644 index 00000000000..f8a2f7b9223 --- /dev/null +++ b/app/controllers/avo/link_verifications_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::LinkVerificationsController < Avo::ResourcesController +end diff --git a/app/controllers/avo/linksets_controller.rb b/app/controllers/avo/linksets_controller.rb new file mode 100644 index 00000000000..5335c2487c7 --- /dev/null +++ b/app/controllers/avo/linksets_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::LinksetsController < Avo::ResourcesController +end diff --git a/app/controllers/avo/log_tickets_controller.rb b/app/controllers/avo/log_tickets_controller.rb new file mode 100644 index 00000000000..4ef05cd195e --- /dev/null +++ b/app/controllers/avo/log_tickets_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::LogTicketsController < Avo::ResourcesController +end diff --git a/app/controllers/avo/maintenance_tasks_runs_controller.rb b/app/controllers/avo/maintenance_tasks_runs_controller.rb new file mode 100644 index 00000000000..aee294eb156 --- /dev/null +++ b/app/controllers/avo/maintenance_tasks_runs_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::MaintenanceTasksRunsController < Avo::ResourcesController +end diff --git a/app/controllers/avo/memberships_controller.rb b/app/controllers/avo/memberships_controller.rb new file mode 100644 index 00000000000..679726950fa --- /dev/null +++ b/app/controllers/avo/memberships_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::MembershipsController < Avo::ResourcesController +end diff --git a/app/controllers/avo/oidc_api_key_roles_controller.rb b/app/controllers/avo/oidc_api_key_roles_controller.rb new file mode 100644 index 00000000000..03b51cf50b8 --- /dev/null +++ b/app/controllers/avo/oidc_api_key_roles_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::OIDCApiKeyRolesController < Avo::ResourcesController +end diff --git a/app/controllers/avo/oidc_id_tokens_controller.rb b/app/controllers/avo/oidc_id_tokens_controller.rb new file mode 100644 index 00000000000..cd5c87356d3 --- /dev/null +++ b/app/controllers/avo/oidc_id_tokens_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::OIDCIdTokensController < Avo::ResourcesController +end diff --git a/app/controllers/avo/oidc_pending_trusted_publishers_controller.rb b/app/controllers/avo/oidc_pending_trusted_publishers_controller.rb new file mode 100644 index 00000000000..44059a5649f --- /dev/null +++ b/app/controllers/avo/oidc_pending_trusted_publishers_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::OIDCPendingTrustedPublishersController < Avo::ResourcesController +end diff --git a/app/controllers/avo/oidc_providers_controller.rb b/app/controllers/avo/oidc_providers_controller.rb new file mode 100644 index 00000000000..2e20964d8aa --- /dev/null +++ b/app/controllers/avo/oidc_providers_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::OIDCProvidersController < Avo::ResourcesController +end diff --git a/app/controllers/avo/oidc_rubygem_trusted_publishers_controller.rb b/app/controllers/avo/oidc_rubygem_trusted_publishers_controller.rb new file mode 100644 index 00000000000..a9559a522d4 --- /dev/null +++ b/app/controllers/avo/oidc_rubygem_trusted_publishers_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::OIDCRubygemTrustedPublishersController < Avo::ResourcesController +end diff --git a/app/controllers/avo/oidc_trusted_publisher_github_actions_controller.rb b/app/controllers/avo/oidc_trusted_publisher_github_actions_controller.rb new file mode 100644 index 00000000000..f35ba803f21 --- /dev/null +++ b/app/controllers/avo/oidc_trusted_publisher_github_actions_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::OIDCTrustedPublisherGitHubActionsController < Avo::ResourcesController +end diff --git a/app/controllers/avo/organizations_controller.rb b/app/controllers/avo/organizations_controller.rb new file mode 100644 index 00000000000..351db7d415e --- /dev/null +++ b/app/controllers/avo/organizations_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::OrganizationsController < Avo::ResourcesController +end diff --git a/app/controllers/avo/ownerships_controller.rb b/app/controllers/avo/ownerships_controller.rb new file mode 100644 index 00000000000..6ff9484d869 --- /dev/null +++ b/app/controllers/avo/ownerships_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::OwnershipsController < Avo::ResourcesController +end diff --git a/app/controllers/avo/rubygems_controller.rb b/app/controllers/avo/rubygems_controller.rb new file mode 100644 index 00000000000..0185c074e75 --- /dev/null +++ b/app/controllers/avo/rubygems_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::RubygemsController < Avo::ResourcesController +end diff --git a/app/controllers/avo/sendgrid_events_controller.rb b/app/controllers/avo/sendgrid_events_controller.rb new file mode 100644 index 00000000000..faacb38b0ae --- /dev/null +++ b/app/controllers/avo/sendgrid_events_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::SendgridEventsController < Avo::ResourcesController +end diff --git a/app/controllers/avo/users_controller.rb b/app/controllers/avo/users_controller.rb new file mode 100644 index 00000000000..774b50274f2 --- /dev/null +++ b/app/controllers/avo/users_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::UsersController < Avo::ResourcesController +end diff --git a/app/controllers/avo/versions_controller.rb b/app/controllers/avo/versions_controller.rb new file mode 100644 index 00000000000..b5dde07080a --- /dev/null +++ b/app/controllers/avo/versions_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::VersionsController < Avo::ResourcesController +end diff --git a/app/controllers/avo/web_hooks_controller.rb b/app/controllers/avo/web_hooks_controller.rb new file mode 100644 index 00000000000..f7d86704917 --- /dev/null +++ b/app/controllers/avo/web_hooks_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::WebHooksController < Avo::ResourcesController +end diff --git a/app/controllers/avo/webauthn_credentials_controller.rb b/app/controllers/avo/webauthn_credentials_controller.rb new file mode 100644 index 00000000000..f16d7df74bc --- /dev/null +++ b/app/controllers/avo/webauthn_credentials_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::WebauthnCredentialsController < Avo::ResourcesController +end diff --git a/app/controllers/avo/webauthn_verifications_controller.rb b/app/controllers/avo/webauthn_verifications_controller.rb new file mode 100644 index 00000000000..16817b148b2 --- /dev/null +++ b/app/controllers/avo/webauthn_verifications_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::WebauthnVerificationsController < Avo::ResourcesController +end diff --git a/app/controllers/concerns/api_keyable.rb b/app/controllers/concerns/api_keyable.rb index 2f6a3943b52..76367e4d516 100644 --- a/app/controllers/concerns/api_keyable.rb +++ b/app/controllers/concerns/api_keyable.rb @@ -19,7 +19,7 @@ def generate_rubygems_key end def legacy_key_defaults - legacy_scopes = ApiKey::API_SCOPES.each_with_object({}) { |k, h| h[k] = true unless k == :show_dashboard } - legacy_scopes.merge(name: "legacy-key") + legacy_scopes = ApiKey::API_SCOPES - ApiKey::EXCLUSIVE_SCOPES + { scopes: legacy_scopes, name: "legacy-key" } end end diff --git a/app/controllers/concerns/application_multifactor_methods.rb b/app/controllers/concerns/application_multifactor_methods.rb new file mode 100644 index 00000000000..d184ee148a8 --- /dev/null +++ b/app/controllers/concerns/application_multifactor_methods.rb @@ -0,0 +1,33 @@ +module ApplicationMultifactorMethods + extend ActiveSupport::Concern + + included do + def redirect_to_new_mfa + message = t("multifactor_auths.setup_required_html") + + if request.path_info == edit_settings_path + flash.now[:notice_html] = message + return + end + + session["mfa_redirect_uri"] = request.path_info + redirect_to edit_settings_path, notice_html: message + end + + def mfa_required_not_yet_enabled? + return false if current_user.nil? + current_user.mfa_required_not_yet_enabled? + end + + def redirect_to_settings_strong_mfa_required + message = t("multifactor_auths.strong_mfa_level_required_html") + session["mfa_redirect_uri"] = request.path_info + redirect_to edit_settings_path, notice_html: message + end + + def mfa_required_weak_level_enabled? + return false if current_user.nil? + current_user.mfa_required_weak_level_enabled? + end + end +end diff --git a/app/controllers/concerns/avo_auditable.rb b/app/controllers/concerns/avo_auditable.rb new file mode 100644 index 00000000000..bf7643f1005 --- /dev/null +++ b/app/controllers/concerns/avo_auditable.rb @@ -0,0 +1,47 @@ +module AvoAuditable + extend ActiveSupport::Concern + + prepended do + include Auditable + + prepend_around_action :unscope_users + end + + def perform_action_and_record_errors(&blk) + super do + action = params.fetch(:action) + fields = action == "destroy" ? {} : cast_nullable(model_params) + + @model.errors.add :comment, "must supply a sufficiently detailed comment" if fields[:comment]&.then { _1.length < 10 } + raise ActiveRecord::RecordInvalid, @model if @model.errors.present? + action_name = "Manual #{action} of #{@model.class}" + + value, @audit = in_audited_transaction( + auditable: @model, + admin_github_user: _current_user, + action: action_name, + fields: fields.reverse_merge(comment: action_name), + arguments: {}, + models: [@model], + &blk + ) + value + end + end + + def after_update_path + return avo.resources_audit_path(@audit) if @audit.present? + + super + end + + def after_create_path + return avo.resources_audit_path(@audit) if @audit.present? + + super + end + + def unscope_users(&) + User.unscoped(&) + end +end diff --git a/app/controllers/concerns/email_resettable.rb b/app/controllers/concerns/email_resettable.rb new file mode 100644 index 00000000000..34a3638f51c --- /dev/null +++ b/app/controllers/concerns/email_resettable.rb @@ -0,0 +1,12 @@ +module EmailResettable + extend ActiveSupport::Concern + + included do + def email_reset(user) + return if user.confirmation_token.blank? + + Mailer.email_reset_update(user).deliver_later if user.email + Mailer.email_reset(user).deliver_later if user.unconfirmed_email + end + end +end diff --git a/app/controllers/concerns/latest_version.rb b/app/controllers/concerns/latest_version.rb index 28485d280c8..3b1b580312e 100644 --- a/app/controllers/concerns/latest_version.rb +++ b/app/controllers/concerns/latest_version.rb @@ -3,11 +3,11 @@ module LatestVersion included do def latest_version - @latest_version ||= @rubygem.versions.most_recent + @latest_version ||= @rubygem.most_recent_version end def latest_version_by_slug - @latest_version = Version.find_by!(full_name: "#{params[:rubygem_id]}-#{params[:version_id]}") + @latest_version = @rubygem.find_version_by_slug!(params.permit(:version_id).require(:version_id)) end end end diff --git a/app/controllers/concerns/maintenance_tasks_auditable.rb b/app/controllers/concerns/maintenance_tasks_auditable.rb new file mode 100644 index 00000000000..c736e992b35 --- /dev/null +++ b/app/controllers/concerns/maintenance_tasks_auditable.rb @@ -0,0 +1,30 @@ +module MaintenanceTasksAuditable + extend ActiveSupport::Concern + + prepended do + include Auditable + around_action :audit_action + + def audit_action(&) + return yield if params[:action].in?(%w[show index]) + + action = params.fetch(:action) + task_name = params.fetch(:task_id) + + action_name = "Manual #{action} of #{task_name}" + + run = @run + + value, _audit = in_audited_transaction( + auditable: run || ->(changed_records:) { changed_records.keys.grep(MaintenanceTasks::Run).sole }, + admin_github_user: admin_user, + action: action_name, + fields: params.slice(:comment).reverse_merge(comment: action_name), + arguments: params, + models: [run].compact, + & + ) + value + end + end +end diff --git a/app/controllers/concerns/mfa_expiry_methods.rb b/app/controllers/concerns/mfa_expiry_methods.rb new file mode 100644 index 00000000000..f743cab38b2 --- /dev/null +++ b/app/controllers/concerns/mfa_expiry_methods.rb @@ -0,0 +1,20 @@ +module MfaExpiryMethods + extend ActiveSupport::Concern + + included do + def create_new_mfa_expiry + session[:mfa_expires_at] = 15.minutes.from_now.to_s + end + + def delete_mfa_expiry_session + session.delete(:mfa_expires_at) + end + + # Clear the session key when mfa has expired. This makes mfa_session_active? before_action guards simpler to write. + def mfa_session_active? + return false if session[:mfa_expires_at].nil? + delete_mfa_expiry_session if Time.current > session[:mfa_expires_at] + session[:mfa_expires_at].present? + end + end +end diff --git a/app/controllers/concerns/require_mfa.rb b/app/controllers/concerns/require_mfa.rb new file mode 100644 index 00000000000..872ed47833d --- /dev/null +++ b/app/controllers/concerns/require_mfa.rb @@ -0,0 +1,71 @@ +module RequireMfa + extend ActiveSupport::Concern + + def require_mfa(user = @user) + return unless user&.mfa_enabled? + initialize_mfa(user) + prompt_mfa + end + + # Call initialize_mfa once at the start of the MFA flow for a user (after login, after reset token verified). + def initialize_mfa(user = @user) + delete_mfa_session + create_new_mfa_expiry + session[:mfa_login_started_at] = Time.now.utc.to_s + session[:mfa_user] = user.id + end + + def prompt_mfa(alert: nil, status: :ok) + @otp_verification_url = otp_verification_url + setup_webauthn_authentication form_url: webauthn_verification_url + flash.now.alert = alert if alert + render template: "multifactor_auths/prompt", status: + end + + def otp_param + params.permit(:otp).fetch(:otp, "") + end + + def validate_otp(user = @user) + return mfa_session_expired unless mfa_session_active? + return mfa_not_enabled unless user&.mfa_enabled? + return incorrect_otp unless user.ui_mfa_verified?(otp_param) + @mfa_label = "OTP" + @mfa_method = "otp" + end + + def validate_webauthn(user = @user) + return mfa_session_expired unless mfa_session_active? + return mfa_not_enabled unless user&.mfa_enabled? + return webauthn_failure unless webauthn_credential_verified? + @mfa_label = user_webauthn_credential.nickname + @mfa_method = "webauthn" + end + + def mfa_session_expired + invalidate_mfa_session(t("multifactor_auths.session_expired")) + end + + def mfa_not_enabled + end + + def incorrect_otp + mfa_failure(t("totps.incorrect_otp")) + end + + def webauthn_failure + mfa_failure(@webauthn_error) + end + + def invalidate_mfa_session(message) + delete_mfa_session + login_failure(message) + end + + def delete_mfa_session + delete_mfa_expiry_session + session.delete(:webauthn_authentication) + session.delete(:mfa_login_started_at) + session.delete(:mfa_user) + end +end diff --git a/app/controllers/concerns/requirements_version.rb b/app/controllers/concerns/requirements_version.rb deleted file mode 100755 index 7bb01f62ae7..00000000000 --- a/app/controllers/concerns/requirements_version.rb +++ /dev/null @@ -1,15 +0,0 @@ -require "rubygems/dependency" - -module RequirementsVersion - extend ActiveSupport::Concern - - included do - def dep_resolver(name, reqs, versions) - reqs = Gem::Dependency.new(name, reqs.split(/\s*,\s*/)) - - versions.each do |ver| - return ver if reqs.match?(name, ver) - end - end - end -end diff --git a/app/controllers/concerns/session_verifiable.rb b/app/controllers/concerns/session_verifiable.rb new file mode 100644 index 00000000000..864e9c95747 --- /dev/null +++ b/app/controllers/concerns/session_verifiable.rb @@ -0,0 +1,40 @@ +module SessionVerifiable + extend ActiveSupport::Concern + + class_methods do + def verify_session_before(**opts) + before_action :redirect_to_signin, **opts, unless: :signed_in? + before_action :redirect_to_new_mfa, **opts, if: :mfa_required_not_yet_enabled? + before_action :redirect_to_settings_strong_mfa_required, **opts, if: :mfa_required_weak_level_enabled? + before_action :redirect_to_verify, **opts, unless: :verified_session_active? + end + end + + private + + def verify_session_redirect_path + redirect_uri = request.path_info + redirect_uri += "?#{request.query_string}" if request.query_string.present? + redirect_uri + end + + included do + private + + def redirect_to_verify + session[:redirect_uri] = verify_session_redirect_path + redirect_to verify_session_path + end + + def session_verified + session[:verified_user] = current_user.id + session[:verification] = Gemcutter::PASSWORD_VERIFICATION_EXPIRY.from_now + end + + def verified_session_active? + session[:verification] && + session[:verification] > Time.current && + session.fetch(:verified_user, "") == current_user.id + end + end +end diff --git a/app/controllers/concerns/webauthn_verifiable.rb b/app/controllers/concerns/webauthn_verifiable.rb new file mode 100644 index 00000000000..b4c8f7eee70 --- /dev/null +++ b/app/controllers/concerns/webauthn_verifiable.rb @@ -0,0 +1,77 @@ +module WebauthnVerifiable + extend ActiveSupport::Concern + + def setup_webauthn_authentication(form_url:, session_options: {}) + return if @user.webauthn_credentials.none? + + @webauthn_options = @user.webauthn_options_for_get + @webauthn_verification_url = form_url + + session[:webauthn_authentication] = { + "challenge" => @webauthn_options.challenge + }.merge(session_options) + end + + def webauthn_credential_verified? + @credential = WebAuthn::Credential.from_get(credential_params) + + unless user_webauthn_credential + @webauthn_error = t("credentials_required") + return false + end + + @credential.verify( + challenge, + public_key: user_webauthn_credential.public_key, + sign_count: user_webauthn_credential.sign_count + ) + user_webauthn_credential.update!(sign_count: @credential.sign_count) + + if @credential.user_handle.present? && @credential.user_handle != user_webauthn_credential.user.webauthn_id + @webauthn_error = t("credentials_required") + return false + end + + true + rescue WebAuthn::Error => e + @webauthn_error = e.message + false + rescue ActionController::ParameterMissing + @webauthn_error = t("credentials_required") + false + ensure + session.delete(:webauthn_authentication) + end + + private + + def webauthn_credential_scope + if @user.present? + @user.webauthn_credentials + else + User.find_by(webauthn_id: @credential.user_handle)&.webauthn_credentials || WebauthnCredential.none + end + end + + def user_webauthn_credential + @user_webauthn_credential ||= webauthn_credential_scope.find_by( + external_id: @credential.id + ) + end + + def challenge + session.dig(:webauthn_authentication, "challenge") + end + + def credential_params + params.permit(credentials: PERMITTED_CREDENTIALS).require(:credentials) + end + + PERMITTED_CREDENTIALS = [ + :id, + :type, + :rawId, + :authenticatorAttachment, + { response: %i[authenticatorData attestationObject clientDataJSON signature userHandle] } + ].freeze +end diff --git a/app/controllers/dashboards_controller.rb b/app/controllers/dashboards_controller.rb index 8e2028f4afa..79e8b999ce6 100644 --- a/app/controllers/dashboards_controller.rb +++ b/app/controllers/dashboards_controller.rb @@ -1,16 +1,18 @@ class DashboardsController < ApplicationController before_action :authenticate_with_api_key, unless: :signed_in? before_action :redirect_to_signin, unless: -> { signed_in? || @api_key&.can_show_dashboard? } + before_action :redirect_to_new_mfa, if: :mfa_required_not_yet_enabled? + before_action :redirect_to_settings_strong_mfa_required, if: :mfa_required_weak_level_enabled? def show respond_to do |format| format.html do - @my_gems = current_user.rubygems.with_versions.by_name - @latest_updates = Version.subscribed_to_by(current_user).published(Gemcutter::DEFAULT_PAGINATION) + @my_gems = current_user.rubygems.with_versions.by_name.preload(:most_recent_version) + @latest_updates = Version.subscribed_to_by(current_user).published.limit(Gemcutter::DEFAULT_PAGINATION) @subscribed_gems = current_user.subscribed_gems.with_versions end format.atom do - @versions = Version.subscribed_to_by(api_or_logged_in_user).published(Gemcutter::DEFAULT_PAGINATION) + @versions = Version.subscribed_to_by(api_or_logged_in_user).published.limit(Gemcutter::DEFAULT_PAGINATION) render "versions/feed" end end @@ -21,7 +23,10 @@ def show def authenticate_with_api_key params_key = request.headers["Authorization"] || params.permit(:api_key).fetch(:api_key, "") hashed_key = Digest::SHA256.hexdigest(params_key) - @api_key = ApiKey.find_by_hashed_key(hashed_key) + return unless (@api_key = ApiKey.unexpired.find_by_hashed_key(hashed_key)) + + set_tags "gemcutter.api_key.owner" => @api_key.owner.to_gid, "gemcutter.api_key" => @api_key.to_gid + render plain: "An invalid API key cannot be used. Please delete it and create a new one.", status: :forbidden if @api_key.soft_deleted? end def api_or_logged_in_user diff --git a/app/controllers/dependencies_controller.rb b/app/controllers/dependencies_controller.rb index f73c2d55181..3fc9608efc6 100644 --- a/app/controllers/dependencies_controller.rb +++ b/app/controllers/dependencies_controller.rb @@ -1,20 +1,17 @@ class DependenciesController < ApplicationController include LatestVersion - include RequirementsVersion + before_action :find_rubygem, only: [:show] + before_action :latest_version_by_slug, only: [:show] def show - latest_version_by_slug @dependencies = Hash.new { |h, k| h[k] = [] } resolvable_dependencies = @latest_version.dependencies.where(unresolved_name: nil) + .strict_loading.preload(rubygem: :public_versions) resolvable_dependencies.each do |dependency| gem_name = dependency.rubygem.name - version = dep_resolver( - gem_name, - dependency.clean_requirements, - dependency.rubygem.public_versions.pluck(:number) - ) + version = dep_resolver(gem_name, dependency, @latest_version.platform) @dependencies[dependency.scope] << [gem_name, version, dependency.requirements] end @@ -26,6 +23,19 @@ def show private + def dep_resolver(name, dependency, platform) + requirements = dependency.clean_requirements + reqs = Gem::Dependency.new(name, requirements.split(/\s*,\s*/)) + + matching_versions = dependency.rubygem.public_versions.select { |v| reqs.match?(name, v.number) } + match = matching_versions.detect { |v| match_platform(platform, v.platform) } || matching_versions.first + match&.slug + end + + def match_platform(platform, dep_platform) + Gem::Platform.new(platform) == Gem::Platform.new(dep_platform) + end + def json_return { run_html: render_str_call("runtime"), @@ -35,6 +45,6 @@ def json_return def render_str_call(scope) local_var = { scope: scope, dependencies: @dependencies, gem_name: @latest_version.rubygem.name } - ActionController::Base.new.render_to_string(partial: "dependencies/dependencies", formats: [:html], locals: local_var) + self.class.renderer.new(request.env).render(partial: "dependencies/dependencies", formats: [:html], locals: local_var) end end diff --git a/app/controllers/email_confirmations_controller.rb b/app/controllers/email_confirmations_controller.rb index 57160ac4546..fb07affec1c 100644 --- a/app/controllers/email_confirmations_controller.rb +++ b/app/controllers/email_confirmations_controller.rb @@ -1,16 +1,18 @@ class EmailConfirmationsController < ApplicationController - before_action :redirect_to_signin, unless: :signed_in?, only: :unconfirmed - - def update - user = User.find_by(confirmation_token: params[:token]) + include EmailResettable + include RequireMfa + include MfaExpiryMethods + include WebauthnVerifiable - if user&.valid_confirmation_token? && user&.confirm_email! - sign_in user - redirect_to root_path, notice: t(".confirmed_email") - else - redirect_to root_path, alert: t("failure_when_forbidden") - end - end + before_action :redirect_to_signin, unless: :signed_in?, only: :unconfirmed + before_action :redirect_to_new_mfa, if: :mfa_required_not_yet_enabled?, only: :unconfirmed + before_action :redirect_to_settings_strong_mfa_required, if: :mfa_required_weak_level_enabled?, only: :unconfirmed + before_action :no_referrer, only: %i[update otp_update webauthn_update] + before_action :validate_confirmation_token, only: %i[update otp_update webauthn_update] + before_action :require_mfa, only: :update + before_action :validate_otp, only: :otp_update + before_action :validate_webauthn, only: :webauthn_update + after_action :delete_mfa_expiry_session, only: %i[otp_update webauthn_update] def new end @@ -20,16 +22,28 @@ def create user = find_user_for_create if user - user.generate_confirmation_token - Delayed::Job.enqueue(EmailConfirmationMailer.new(user.id)) if user.save + user.generate_confirmation_token(reset_unconfirmed_email: false) + Mailer.email_confirmation(user).deliver_later if user.save end redirect_to root_path, notice: t(".promise_resend") end + def update + confirm_email + end + + def otp_update + confirm_email + end + + def webauthn_update + confirm_email + end + # used to resend confirmation mail for unconfirmed_email validation def unconfirmed - if current_user.generate_confirmation_token && current_user.save - Delayed::Job.enqueue EmailResetMailer.new(current_user.id) + if current_user.generate_confirmation_token(reset_unconfirmed_email: false) && current_user.save + email_reset(current_user) flash[:notice] = t("profiles.update.confirmation_mail_sent") else flash[:notice] = t("try_again") @@ -43,7 +57,42 @@ def find_user_for_create Clearance.configuration.user_model.find_by_normalized_email email_params end + def validate_confirmation_token + @user = User.find_by(confirmation_token: token_params) + redirect_to root_path, alert: t("email_confirmations.update.token_failure") unless @user&.valid_confirmation_token? + end + + def confirm_email + if @user.confirm_email! + flash[:notice] = t("email_confirmations.update.confirmed_email") + else + flash[:alert] = @user.errors.full_messages.to_sentence + end + redirect_to signed_in? ? dashboard_path : sign_in_path + end + def email_params - params.require(:email_confirmation).permit(:email).fetch(:email, "") + params.permit(email_confirmation: :email).require(:email_confirmation).require(:email) + end + + def token_params + params.permit(:token).require(:token) + end + + def login_failure(message) + flash.now.alert = message + render template: "multifactor_auths/prompt", status: :unauthorized + end + + def otp_verification_url + otp_update_email_confirmations_url(token: @user.confirmation_token) + end + + def webauthn_verification_url + webauthn_update_email_confirmations_url(token: @user.confirmation_token) + end + + def mfa_failure(alert) + login_failure(alert) end end diff --git a/app/controllers/multifactor_auths_controller.rb b/app/controllers/multifactor_auths_controller.rb index 5e38aca2746..ccc4fe14113 100644 --- a/app/controllers/multifactor_auths_controller.rb +++ b/app/controllers/multifactor_auths_controller.rb @@ -1,75 +1,96 @@ class MultifactorAuthsController < ApplicationController + include MfaExpiryMethods + include RequireMfa + include WebauthnVerifiable + before_action :redirect_to_signin, unless: :signed_in? - before_action :require_mfa_disabled, only: %i[new create] - before_action :require_mfa_enabled, only: :update - before_action :seed_and_expire, only: :create + before_action :require_mfa_enabled, only: %i[update otp_update] + before_action :find_mfa_user, only: %i[update otp_update webauthn_update] + before_action :require_mfa, only: %i[update] + before_action :validate_otp, only: %i[otp_update] + before_action :require_webauthn_enabled, only: %i[webauthn_update] + before_action :validate_webauthn, only: %i[webauthn_update] + before_action :disable_cache, only: %i[recovery] + after_action :delete_mfa_session, only: %i[otp_update webauthn_update] helper_method :issuer - def new - @seed = ROTP::Base32.random_base32 - session[:mfa_seed] = @seed - session[:mfa_seed_expire] = Gemcutter::MFA_KEY_EXPIRY.from_now.utc.to_i - text = ROTP::TOTP.new(@seed, issuer: issuer).provisioning_uri(current_user.email) - @qrcode_svg = RQRCode::QRCode.new(text, level: :l).as_svg(module_size: 6) + # not possible to arrive here because of require_mfa_enabled + require_mfa, but it must stay for rails to be happy. + def update end - def create - current_user.verify_and_enable_mfa!(@seed, :ui_and_api, otp_param, @expire) - if current_user.errors.any? - flash[:error] = current_user.errors[:base].join - redirect_to edit_settings_url - else - flash[:success] = t(".success") - render :recovery - end + def otp_update + update_level_and_redirect end - def update - if current_user.otp_verified?(otp_param) - if level_param == "disabled" - flash[:success] = t("multifactor_auths.destroy.success") - current_user.disable_mfa! - else - flash[:error] = t(".success") - current_user.update!(mfa_level: level_param) - end - else - flash[:error] = t("multifactor_auths.incorrect_otp") + def webauthn_update + update_level_and_redirect + end + + def recovery + @mfa_recovery_codes = session[:show_recovery_codes] + if @mfa_recovery_codes.nil? + redirect_to edit_settings_path + flash[:error] = t(".already_generated") + return end - redirect_to edit_settings_url + @continue_path = session.fetch("mfa_redirect_uri", edit_settings_path) + session.delete("mfa_redirect_uri") + ensure + session.delete(:show_recovery_codes) end private - def otp_param - params.permit(:otp).fetch(:otp, "") - end - def level_param - params.permit(:level).fetch(:level, "") + params.permit(:level).require(:level) end def issuer request.host || "rubygems.org" end - def require_mfa_disabled - return unless current_user.mfa_enabled? - flash[:error] = t("multifactor_auths.require_mfa_disabled") - redirect_to edit_settings_path - end - def require_mfa_enabled return if current_user.mfa_enabled? flash[:error] = t("multifactor_auths.require_mfa_enabled") redirect_to edit_settings_path end - def seed_and_expire - @seed = session[:mfa_seed] - @expire = Time.at(session[:mfa_seed_expire] || 0).utc - %i[mfa_seed mfa_seed_expire].each do |key| - session.delete(key) + def require_webauthn_enabled + return if current_user.webauthn_enabled? + + flash[:error] = t("multifactor_auths.require_webauthn_enabled") + delete_mfa_session + redirect_to edit_settings_path + end + + def update_level_and_redirect + case level_param + when "ui_and_api", "ui_and_gem_signin" + flash[:success] = t("multifactor_auths.update.success") + current_user.update!(mfa_level: level_param) + else + flash[:error] = t("multifactor_auths.update.invalid_level") # rubocop:disable Rails/ActionControllerFlashBeforeRender end + + redirect_to session.fetch("mfa_redirect_uri", edit_settings_path) + session.delete(:mfa_redirect_uri) + end + + def find_mfa_user + @user = current_user + end + + def mfa_failure(message) + delete_mfa_session + redirect_to edit_settings_path, flash: { error: message } + end + alias login_failure mfa_failure + + def otp_verification_url + otp_update_multifactor_auth_url(level: level_param) + end + + def webauthn_verification_url + webauthn_update_multifactor_auth_url(level: level_param) end end diff --git a/app/controllers/notifiers_controller.rb b/app/controllers/notifiers_controller.rb index 692091ee116..99ee54e0eb6 100644 --- a/app/controllers/notifiers_controller.rb +++ b/app/controllers/notifiers_controller.rb @@ -1,28 +1,40 @@ class NotifiersController < ApplicationController before_action :redirect_to_signin, unless: :signed_in? + before_action :redirect_to_new_mfa, if: :mfa_required_not_yet_enabled? + before_action :redirect_to_settings_strong_mfa_required, if: :mfa_required_weak_level_enabled? def show - @ownerships = current_user.ownerships.by_indexed_gem_name + @ownerships = current_user.ownerships.by_indexed_gem_name.includes(:rubygem) end def update - to_enable_push = [] - to_disable_push = [] - to_enable_owner = [] - to_disable_owner = [] - params.require(:ownerships).each do |ownership_id, notifier| - (notifier["owner"] == "off" ? to_disable_owner : to_enable_owner) << ownership_id.to_i - (notifier["push"] == "off" ? to_disable_push : to_enable_push) << ownership_id.to_i - end + to_enable_push, to_disable_push = notifier_options("push") + to_enable_owner, to_disable_owner = notifier_options("owner") + to_enable_ownership_request, to_disable_ownership_request = notifier_options("ownership_request") current_user.transaction do - current_user.ownerships.where(id: to_enable_push).update_all(push_notifier: true) if to_enable_push.any? - current_user.ownerships.where(id: to_disable_push).update_all(push_notifier: false) if to_disable_push.any? - current_user.ownerships.where(id: to_enable_owner).update_all(owner_notifier: true) if to_enable_owner.any? - current_user.ownerships.where(id: to_disable_owner).update_all(owner_notifier: false) if to_disable_owner.any? - Mailer.delay.notifiers_changed(current_user.id) + current_user.ownerships.update_push_notifier(to_enable_push, to_disable_push) + current_user.ownerships.update_owner_notifier(to_enable_owner, to_disable_owner) + current_user.ownerships.update_ownership_request_notifier(to_enable_ownership_request, to_disable_ownership_request) + Mailer.notifiers_changed(current_user.id).deliver_later end redirect_to notifier_path, notice: t(".update.success") end + + private + + def notifier_params + params.permit(ownerships: %i[push owner ownership_request]).require(:ownerships) + end + + def notifier_options(param) + to_enable = [] + to_disable = [] + notifier_params.each do |ownership_id, notifier| + (notifier[param] == "off" ? to_disable : to_enable) << ownership_id.to_i + end + + [to_enable, to_disable] + end end diff --git a/app/controllers/oauth_controller.rb b/app/controllers/oauth_controller.rb new file mode 100644 index 00000000000..525e4db919e --- /dev/null +++ b/app/controllers/oauth_controller.rb @@ -0,0 +1,39 @@ +class OAuthController < ApplicationController + include GitHubOAuthable + + rescue_from Octokit::ClientError, Octokit::ServerError, with: :render_not_found + rescue_from ActiveModel::ValidationError do |e| + render_forbidden e.message + end + + with_options only: :create do + before_action :check_valid_omniauth + before_action :check_supported_omniauth_provider + end + + def create + admin_github_login!(token: request.env["omniauth.auth"].credentials.token) + + redirect_to request.env["omniauth.origin"].presence || avo_root_path + end + + def failure + render_forbidden params.permit(:message).require(:message) + end + + def development_log_in_as + user = Admin::GitHubUser.find(params[:admin_github_user_id]) + log_in_as(user:) + redirect_back_or_to "/admin" + end + + private + + def check_valid_omniauth + render_not_found unless request.env["omniauth.auth"]&.valid? + end + + def check_supported_omniauth_provider + render_not_found unless request.env["omniauth.auth"].provider == "github" + end +end diff --git a/app/controllers/oidc/api_key_roles_controller.rb b/app/controllers/oidc/api_key_roles_controller.rb new file mode 100644 index 00000000000..77c1facd77a --- /dev/null +++ b/app/controllers/oidc/api_key_roles_controller.rb @@ -0,0 +1,149 @@ +class OIDC::ApiKeyRolesController < ApplicationController + include ApiKeyable + + include SessionVerifiable + verify_session_before + + helper RubygemsHelper + + before_action :find_api_key_role, except: %i[index new create] + before_action :redirect_for_deleted, only: %i[edit update destroy] + before_action :set_page, only: :index + + def index + @api_key_roles = current_user.oidc_api_key_roles.active.includes(:provider) + .page(@page) + .strict_loading + end + + def show + @id_tokens = @api_key_role.id_tokens.order(id: :desc).includes(:api_key) + .page(0).per(10) + .strict_loading + respond_to do |format| + format.json do + render json: @api_key_role + end + format.html + end + end + + def github_actions_workflow + render OIDC::ApiKeyRoles::GitHubActionsWorkflowView.new(api_key_role: @api_key_role) + end + + def new + rubygem = Rubygem.find_by(name: params[:rubygem]) + scopes = params.permit(scopes: []).fetch(:scopes, []) + + @api_key_role = current_user.oidc_api_key_roles.build + @api_key_role.api_key_permissions = OIDC::ApiKeyPermissions.new(gems: [], scopes: scopes) + + if rubygem + existing_role_names = current_user.oidc_api_key_roles.where("name ILIKE ?", "Push #{rubygem.name}%").pluck(:name) + @api_key_role.api_key_permissions.gems = [rubygem.name] + @api_key_role.name = if existing_role_names.present? + "Push #{rubygem.name} #{existing_role_names.length + 1}" + else + "Push #{rubygem.name}" + end + end + + condition = OIDC::AccessPolicy::Statement::Condition.new + statement = OIDC::AccessPolicy::Statement.new(conditions: [condition]) + add_default_params(rubygem, statement, condition) + + @api_key_role.access_policy = OIDC::AccessPolicy.new(statements: [statement]) + end + + def edit + end + + def create + @api_key_role = current_user.oidc_api_key_roles.build(api_key_role_params) + if @api_key_role.save + redirect_to profile_oidc_api_key_role_path(@api_key_role.token), flash: { notice: t(".success") } + else + flash.now[:error] = @api_key_role.errors.full_messages.to_sentence + render :new + end + end + + def update + if @api_key_role.update(api_key_role_params) + redirect_to profile_oidc_api_key_role_path(@api_key_role.token), flash: { notice: t(".success") } + else + flash.now[:error] = @api_key_role.errors.full_messages.to_sentence + render :edit + end + end + + def destroy + if @api_key_role.update(deleted_at: Time.current) + redirect_to profile_oidc_api_key_roles_path, flash: { notice: t(".success") } + else + redirect_to profile_oidc_api_key_role_path(@api_key_role.token), + flash: { error: @api_key_role.errors.full_messages.to_sentence } + end + end + + private + + def verify_session_redirect_path + case action_name + when "create" + new_profile_api_key_path + when "update" + edit_profile_api_key_path + else + super + end + end + + def find_api_key_role + @api_key_role = current_user.oidc_api_key_roles + .includes(:provider) + .find_by!(token: params.permit(:token).require(:token)) + end + + def redirect_for_deleted + redirect_to profile_oidc_api_key_roles_path, flash: { error: t(".deleted") } if @api_key_role.deleted_at? + end + + PERMITTED_API_KEY_ROLE_PARAMS = [ + :name, + :oidc_provider_id, + { + api_key_permissions: [:valid_for, { scopes: [], gems: [] }], + access_policy: { + statements_attributes: [ + :effect, + { principal: :oidc, conditions_attributes: %i[operator claim value] } + ] + } + } + ].freeze + + def api_key_role_params + params.permit(oidc_api_key_role: PERMITTED_API_KEY_ROLE_PARAMS).require(:oidc_api_key_role) + end + + def add_default_params(rubygem, statement, condition) + condition.claim = "aud" + condition.operator = "string_equals" + condition.value = Gemcutter::HOST + + return unless rubygem + return unless (gh = helpers.link_to_github(rubygem)).presence + return unless (@api_key_role.provider = OIDC::Provider.github_actions) + + statement.principal = { oidc: @api_key_role.provider.issuer } + + repo_condition = OIDC::AccessPolicy::Statement::Condition.new( + claim: "repository", + operator: "string_equals", + value: gh.path.split("/")[1, 2].join("/") + ) + statement.conditions << repo_condition + end +end diff --git a/app/controllers/oidc/concerns/trusted_publisher_creation.rb b/app/controllers/oidc/concerns/trusted_publisher_creation.rb new file mode 100644 index 00000000000..480c4e5c9f8 --- /dev/null +++ b/app/controllers/oidc/concerns/trusted_publisher_creation.rb @@ -0,0 +1,21 @@ +module OIDC::Concerns::TrustedPublisherCreation + extend ActiveSupport::Concern + + included do + include SessionVerifiable + verify_session_before + + before_action :set_trusted_publisher_type, only: %i[create] + before_action :create_params, only: %i[create] + before_action :set_page, only: :index + end + + def set_trusted_publisher_type + trusted_publisher_type = params.permit(create_params_key => :trusted_publisher_type).require(create_params_key).require(:trusted_publisher_type) + + @trusted_publisher_type = OIDC::TrustedPublisher.all.find { |type| type.polymorphic_name == trusted_publisher_type } + + return if @trusted_publisher_type + redirect_back fallback_location: root_path, flash: { error: t("oidc.trusted_publisher.unsupported_type") } + end +end diff --git a/app/controllers/oidc/id_tokens_controller.rb b/app/controllers/oidc/id_tokens_controller.rb new file mode 100644 index 00000000000..6cf65e586f1 --- /dev/null +++ b/app/controllers/oidc/id_tokens_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class OIDC::IdTokensController < ApplicationController + include ApiKeyable + + include SessionVerifiable + verify_session_before + + before_action :find_id_token, except: %i[index] + before_action :set_page, only: :index + + def index + id_tokens = current_user.oidc_id_tokens.includes(:api_key, :api_key_role, :provider) + .page(@page) + .strict_loading + render OIDC::IdTokens::IndexView.new(id_tokens:) + end + + def show + render OIDC::IdTokens::ShowView.new(id_token: @id_token) + end + + private + + def find_id_token + @id_token = current_user.oidc_id_tokens.find(params.permit(:id).require(:id)) + end +end diff --git a/app/controllers/oidc/pending_trusted_publishers_controller.rb b/app/controllers/oidc/pending_trusted_publishers_controller.rb new file mode 100644 index 00000000000..170fa485448 --- /dev/null +++ b/app/controllers/oidc/pending_trusted_publishers_controller.rb @@ -0,0 +1,65 @@ +class OIDC::PendingTrustedPublishersController < ApplicationController + include OIDC::Concerns::TrustedPublisherCreation + + before_action :find_pending_trusted_publisher, only: %i[destroy] + + def index + trusted_publishers = policy_scope(OIDC::PendingTrustedPublisher) + .unexpired.includes(:trusted_publisher) + .order(:rubygem_name, :created_at).page(@page).strict_loading + render OIDC::PendingTrustedPublishers::IndexView.new( + trusted_publishers: + ) + end + + def new + pending_trusted_publisher = current_user.oidc_pending_trusted_publishers.new(trusted_publisher: OIDC::TrustedPublisher::GitHubAction.new) + render OIDC::PendingTrustedPublishers::NewView.new( + pending_trusted_publisher: + ) + end + + def create + trusted_publisher = authorize current_user.oidc_pending_trusted_publishers.new( + create_params.merge( + expires_at: 12.hours.from_now + ) + ) + + if trusted_publisher.save + redirect_to profile_oidc_pending_trusted_publishers_path, flash: { notice: t(".success") } + else + flash.now[:error] = trusted_publisher.errors.full_messages.to_sentence + render OIDC::PendingTrustedPublishers::NewView.new( + pending_trusted_publisher: trusted_publisher + ), status: :unprocessable_entity + end + end + + def destroy + if @pending_trusted_publisher.destroy + redirect_to profile_oidc_pending_trusted_publishers_path, flash: { notice: t(".success") } + else + redirect_back fallback_location: profile_oidc_pending_trusted_publishers_path, + flash: { error: @pending_trusted_publisher.errors.full_messages.to_sentence } + end + end + + private + + def create_params + params.permit( + create_params_key => [ + :rubygem_name, + :trusted_publisher_type, + { trusted_publisher_attributes: @trusted_publisher_type.permitted_attributes } + ] + ).require(create_params_key) + end + + def create_params_key = :oidc_pending_trusted_publisher + + def find_pending_trusted_publisher + @pending_trusted_publisher = authorize current_user.oidc_pending_trusted_publishers.find(params.permit(:id).require(:id)) + end +end diff --git a/app/controllers/oidc/providers_controller.rb b/app/controllers/oidc/providers_controller.rb new file mode 100644 index 00000000000..5bd58d3c759 --- /dev/null +++ b/app/controllers/oidc/providers_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class OIDC::ProvidersController < ApplicationController + include SessionVerifiable + verify_session_before + + before_action :find_provider, except: %i[index] + before_action :set_page, only: :index + + def index + providers = OIDC::Provider.strict_loading.page(@page) + render OIDC::Providers::IndexView.new(providers:) + end + + def show + render OIDC::Providers::ShowView.new(provider: @provider) + end + + private + + def find_provider + @provider = OIDC::Provider.find(params.permit(:id).require(:id)) + end +end diff --git a/app/controllers/oidc/rubygem_trusted_publishers_controller.rb b/app/controllers/oidc/rubygem_trusted_publishers_controller.rb new file mode 100644 index 00000000000..14ba48a3faf --- /dev/null +++ b/app/controllers/oidc/rubygem_trusted_publishers_controller.rb @@ -0,0 +1,81 @@ +class OIDC::RubygemTrustedPublishersController < ApplicationController + include OIDC::Concerns::TrustedPublisherCreation + + before_action :find_rubygem + before_action :find_rubygem_trusted_publisher, except: %i[index new create] + + def index + render OIDC::RubygemTrustedPublishers::IndexView.new( + rubygem: @rubygem, + trusted_publishers: @rubygem.oidc_rubygem_trusted_publishers.includes(:trusted_publisher).page(@page).strict_loading + ) + end + + def new + render OIDC::RubygemTrustedPublishers::NewView.new( + rubygem_trusted_publisher: @rubygem.oidc_rubygem_trusted_publishers.new(trusted_publisher: gh_actions_trusted_publisher) + ) + end + + def create + trusted_publisher = authorize @rubygem.oidc_rubygem_trusted_publishers.new(create_params) + if trusted_publisher.save + redirect_to rubygem_trusted_publishers_path(@rubygem.slug), flash: { notice: t(".success") } + else + flash.now[:error] = trusted_publisher.errors.full_messages.to_sentence + render OIDC::RubygemTrustedPublishers::NewView.new( + rubygem_trusted_publisher: trusted_publisher + ), status: :unprocessable_entity + end + end + + def destroy + if @rubygem_trusted_publisher.destroy + redirect_to rubygem_trusted_publishers_path(@rubygem.slug), flash: { notice: t(".success") } + else + redirect_back fallback_location: rubygem_trusted_publishers_path(@rubygem.slug), + flash: { error: @rubygem_trusted_publisher.errors.full_messages.to_sentence } + end + end + + private + + def create_params + params.permit( + create_params_key => [ + :trusted_publisher_type, + { trusted_publisher_attributes: @trusted_publisher_type.permitted_attributes } + ] + ).require(create_params_key) + end + + def create_params_key = :oidc_rubygem_trusted_publisher + + def find_rubygem + super + authorize @rubygem, :configure_trusted_publishers? + end + + def find_rubygem_trusted_publisher + @rubygem_trusted_publisher = authorize @rubygem.oidc_rubygem_trusted_publishers.find(params.permit(:id).require(:id)) + end + + def gh_actions_trusted_publisher + github_params = helpers.github_params(@rubygem) + + publisher = OIDC::TrustedPublisher::GitHubAction.new + if github_params + publisher.repository_owner = github_params[:user] + publisher.repository_name = github_params[:repo] + publisher.workflow_filename = workflow_filename(publisher.repository) + end + publisher + end + + def workflow_filename(repo) + paths = Octokit.contents(repo, path: ".github/workflows").lazy.select { _1.type == "file" }.map(&:name).grep(/\.ya?ml\z/) + paths.max_by { |path| [path.include?("release"), path.include?("push")].map! { (_1 && 1) || 0 } } + rescue Octokit::NotFound, Octokit::InvalidRepository + nil + end +end diff --git a/app/controllers/owners_controller.rb b/app/controllers/owners_controller.rb index 7857a01a403..1c393af0dfa 100644 --- a/app/controllers/owners_controller.rb +++ b/app/controllers/owners_controller.rb @@ -1,14 +1,21 @@ class OwnersController < ApplicationController + include SessionVerifiable + before_action :find_rubygem, except: :confirm - before_action :render_forbidden, unless: :owner?, except: %i[confirm resend_confirmation] - before_action :redirect_to_verify, unless: :password_session_active?, only: %i[index create destroy] + verify_session_before only: %i[index edit update create destroy] + before_action :verify_mfa_requirement, only: %i[create edit update destroy] + before_action :find_ownership, only: %i[edit update destroy] + + rescue_from(Pundit::NotAuthorizedError) do |e| + redirect_to rubygem_path(@rubygem.slug), alert: e.policy.error + end def confirm ownership = Ownership.find_by!(token: token_params) if ownership.valid_confirmation_token? && ownership.confirm! notify_owner_added(ownership) - redirect_to rubygem_path(ownership.rubygem), notice: t(".confirmed_email", gem: ownership.rubygem.name) + redirect_to rubygem_path(ownership.rubygem.slug), notice: t(".confirmed_email", gem: ownership.rubygem.name) else redirect_to root_path, alert: t(".token_expired") end @@ -17,34 +24,51 @@ def confirm def resend_confirmation ownership = @rubygem.unconfirmed_ownerships.find_by!(user: current_user) if ownership.generate_confirmation_token && ownership.save - OwnersMailer.delay.ownership_confirmation(ownership.id) + OwnersMailer.ownership_confirmation(ownership).deliver_later flash[:notice] = t(".resent_notice") else flash[:alert] = t("try_again") end - redirect_to rubygem_path(ownership.rubygem) + redirect_to rubygem_path(ownership.rubygem.slug) end def index + authorize @rubygem, :show_unconfirmed_ownerships? @ownerships = @rubygem.ownerships_including_unconfirmed.includes(:user, :authorizer) end + def edit + end + def create + authorize @rubygem, :add_owner? owner = User.find_by_name(handle_params) - ownership = @rubygem.ownerships.new(user: owner, authorizer: current_user) + + ownership = @rubygem.ownerships.new(user: owner, authorizer: current_user, role: params[:role]) if ownership.save - OwnersMailer.delay.ownership_confirmation(ownership.id) - redirect_to rubygem_owners_path(@rubygem), notice: t(".success_notice", handle: owner.name) + OwnersMailer.ownership_confirmation(ownership).deliver_later + redirect_to rubygem_owners_path(@rubygem.slug), notice: t(".success_notice", handle: owner.name) else index_with_error ownership.errors.full_messages.to_sentence, :unprocessable_entity end end + # This action is used to update a user's owenrship role. This endpoint currently asssumes + # the role is the only thing that can be updated. If more fields are added to the ownership + # this action will need to be tweaked a bit + def update + if @ownership.update(update_params) + OwnersMailer.with(ownership: @ownership, authorizer: current_user).owner_updated.deliver_later + redirect_to rubygem_owners_path(@ownership.rubygem.slug), notice: t(".success_notice", handle: @ownership.user.name) + else + index_with_error @ownership.errors.full_messages.to_sentence, :unprocessable_entity + end + end + def destroy - @ownership = @rubygem.ownerships_including_unconfirmed.find_by_owner_handle!(handle_params) if @ownership.safe_destroy - OwnersMailer.delay.owner_removed(@ownership.user_id, current_user.id, @ownership.rubygem_id) - redirect_to rubygem_owners_path(@ownership.rubygem), notice: t(".removed_notice", owner_name: @ownership.owner_name) + OwnersMailer.owner_removed(@ownership.user_id, current_user.id, @ownership.rubygem_id).deliver_later + redirect_to rubygem_owners_path(@ownership.rubygem.slug), notice: t(".removed_notice", owner_name: @ownership.owner_name) else index_with_error t(".failed_notice"), :forbidden end @@ -52,35 +76,50 @@ def destroy private - def owner? - @rubygem.owned_by?(current_user) + def verify_session_redirect_path + rubygem_owners_url(params[:rubygem_id]) end - def redirect_to_verify - session[:redirect_uri] = rubygem_owners_url(@rubygem) - redirect_to verify_session_path + def find_ownership + @ownership = @rubygem.ownerships_including_unconfirmed.find_by_owner_handle(handle_params) + return authorize(@ownership) if @ownership + + predicate = params[:action] == "destroy" ? :remove_owner? : :update_owner? + authorize(@rubygem, predicate) + render_not_found end def token_params - params.require(:token) + params.permit(:token).require(:token) end def handle_params - params.require(:handle) + params.permit(:handle).require(:handle) + end + + def update_params + params.permit(:role) end def notify_owner_added(ownership) ownership.rubygem.ownership_notifiable_owners.each do |notified_user| - OwnersMailer.delay.owner_added(notified_user.id, + OwnersMailer.owner_added( + notified_user.id, ownership.user_id, ownership.authorizer.id, - ownership.rubygem_id) + ownership.rubygem_id + ).deliver_later end end def index_with_error(msg, status) @ownerships = @rubygem.ownerships_including_unconfirmed.includes(:user, :authorizer) - flash[:alert] = msg + flash.now[:alert] = msg render :index, status: status end + + def verify_mfa_requirement + return if @rubygem.mfa_requirement_satisfied_for?(current_user) + index_with_error t("owners.mfa_required"), :forbidden + end end diff --git a/app/controllers/ownership_calls_controller.rb b/app/controllers/ownership_calls_controller.rb new file mode 100644 index 00000000000..67fd36844cf --- /dev/null +++ b/app/controllers/ownership_calls_controller.rb @@ -0,0 +1,46 @@ +class OwnershipCallsController < ApplicationController + include SessionVerifiable + + before_action :find_rubygem, except: :index + before_action :redirect_to_signin, unless: :signed_in?, except: :index + before_action :redirect_to_new_mfa, if: :mfa_required_not_yet_enabled?, except: :index + before_action :redirect_to_settings_strong_mfa_required, if: :mfa_required_weak_level_enabled?, except: :index + before_action :redirect_to_verify, only: %i[create close], unless: :verified_session_active? + before_action :find_ownership_call, only: :close + + rescue_from ActiveRecord::RecordInvalid, with: :redirect_try_again + rescue_from ActiveRecord::RecordNotSaved, with: :redirect_try_again + + def index + set_page + @ownership_calls = OwnershipCall.opened.includes(:user, rubygem: %i[latest_version gem_download]).order(created_at: :desc) + .page(@page) + .per(Gemcutter::OWNERSHIP_CALLS_PER_PAGE) + end + + def create + @ownership_call = authorize @rubygem.ownership_calls.new(user: current_user, note: params[:note]) + if @ownership_call.save + redirect_to rubygem_adoptions_path(@rubygem.slug), notice: t(".success_notice", gem: @rubygem.name) + else + redirect_to rubygem_adoptions_path(@rubygem.slug), alert: @ownership_call.errors.full_messages.to_sentence + end + end + + def close + @ownership_call.close! + redirect_to rubygem_path(@rubygem.slug), notice: t("ownership_calls.update.success_notice", gem: @rubygem.name) + end + + private + + def find_ownership_call + @ownership_call = @rubygem.ownership_call + return redirect_try_again unless @ownership_call + authorize @ownership_call + end + + def redirect_try_again(_exception = nil) + redirect_to rubygem_adoptions_path(@rubygem.slug), alert: t("try_again") + end +end diff --git a/app/controllers/ownership_requests_controller.rb b/app/controllers/ownership_requests_controller.rb new file mode 100644 index 00000000000..48d8f887e05 --- /dev/null +++ b/app/controllers/ownership_requests_controller.rb @@ -0,0 +1,56 @@ +class OwnershipRequestsController < ApplicationController + include SessionVerifiable + + before_action :find_rubygem + before_action :redirect_to_signin, unless: :signed_in? + before_action :redirect_to_new_mfa, if: :mfa_required_not_yet_enabled? + before_action :redirect_to_settings_strong_mfa_required, if: :mfa_required_weak_level_enabled? + before_action :redirect_to_verify, only: %i[update close_all], if: -> { policy(@rubygem).manage_adoption? && !verified_session_active? } + + rescue_from ActiveRecord::RecordInvalid, with: :redirect_try_again + rescue_from ActiveRecord::RecordNotSaved, with: :redirect_try_again + + def create + ownership_request = authorize @rubygem.ownership_requests.new( + ownership_call: @rubygem.ownership_call, + user: current_user, + note: params[:note] + ) + if ownership_request.save + redirect_to rubygem_adoptions_path(@rubygem.slug), notice: t(".success_notice") + else + redirect_to rubygem_adoptions_path(@rubygem.slug), alert: ownership_request.errors.full_messages.to_sentence + end + end + + def update + @ownership_request = OwnershipRequest.find(params[:id]) + + case params.permit(:status).require(:status) + when "close" then close + when "approve" then approve + else redirect_try_again + end + end + + def close_all + authorize(@rubygem, :manage_adoption?).ownership_requests.each(&:close!) + redirect_to rubygem_adoptions_path(@rubygem.slug), notice: t("ownership_requests.close.success_notice", gem: @rubygem.name) + end + + private + + def approve + authorize(@ownership_request, :approve?).approve!(current_user) + redirect_to rubygem_adoptions_path(@rubygem.slug), notice: t(".approved_notice", name: current_user.display_id) + end + + def close + authorize(@ownership_request, :close?).close!(current_user) + redirect_to rubygem_adoptions_path(@rubygem.slug), notice: t(".closed_notice") + end + + def redirect_try_again(_exception = nil) + redirect_to rubygem_adoptions_path(@rubygem.slug), alert: t("try_again") + end +end diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb new file mode 100644 index 00000000000..767ce234925 --- /dev/null +++ b/app/controllers/pages_controller.rb @@ -0,0 +1,15 @@ +class PagesController < ApplicationController + before_action :find_page + + def show + render @page + end + + private + + def find_page + id = params.permit(:id).require(:id) + raise ActionController::RoutingError, "Page not found" unless Gemcutter::PAGES.include?(id) + @page = id + end +end diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb index 76debb1fb3d..9a20498dcfd 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -1,49 +1,117 @@ -class PasswordsController < Clearance::PasswordsController - before_action :validate_confirmation_token, only: %i[edit mfa_edit] +class PasswordsController < ApplicationController + include MfaExpiryMethods + include RequireMfa + include WebauthnVerifiable + + before_action :ensure_email_present, only: %i[create] + + before_action :no_referrer, only: %i[edit otp_edit webauthn_edit] + before_action :validate_confirmation_token, only: %i[edit otp_edit webauthn_edit] + before_action :require_mfa, only: %i[edit] + before_action :validate_otp, only: %i[otp_edit] + before_action :validate_webauthn, only: %i[webauthn_edit] + before_action :password_reset_session_verified, only: %i[edit otp_edit webauthn_edit] + after_action :delete_mfa_expiry_session, only: %i[otp_edit webauthn_edit] + + before_action :validate_password_reset_session, only: :update + + def new + end def edit - if @user.mfa_enabled? - render template: "passwords/otp_prompt" - else - render template: "passwords/edit" - end + render :edit end - def update - @user = find_user_for_update + def create + user = User.find_by_normalized_email(@email) - if @user.update_password password_from_password_reset_params - @user.reset_api_key! if reset_params[:reset_api_key] == "true" - sign_in @user - redirect_to url_after_update - session[:password_reset_token] = nil - else - flash_failure_after_update - render template: "passwords/edit" + if user + user.forgot_password! + ::PasswordMailer.change_password(user).deliver_later end + + render :create, status: :accepted end - def mfa_edit - if @user.mfa_enabled? && @user.otp_verified?(params[:otp]) - render template: "passwords/edit" + def update + if @user.update_password reset_params[:password] + @user.reset_api_key! if reset_params[:reset_api_key] == "true" # singular + @user.api_keys.expire_all! if reset_params[:reset_api_keys] == "true" # plural + delete_password_reset_session + redirect_to signed_in? ? dashboard_path : sign_in_path else - flash.now.alert = t("multifactor_auths.incorrect_otp") - render template: "passwords/otp_prompt", status: :unauthorized + flash.now[:alert] = t(".failure") + render :edit end end + def otp_edit + render :edit + end + + def webauthn_edit + render :edit + end + private - def url_after_update - dashboard_path + def ensure_email_present + @email = params.dig(:password, :email) + return if @email.present? + + flash.now[:alert] = t(".failure_on_missing_email") + render template: "passwords/new", status: :unprocessable_entity + end + + def validate_confirmation_token + confirmation_token = params.permit(:token).fetch(:token, "").to_s + @user = User.find_by(confirmation_token:) + return login_failure(t("passwords.edit.token_failure")) unless @user&.valid_confirmation_token? + sign_out if signed_in? && @user != current_user + end + + # The order of these methods intends to leave the session fully reset if we + # fail to invalidate the token for some reason, since this would indicate + # something is wrong with the user, necessitating help from an admin. + def password_reset_session_verified + reset_session + @user.update!(confirmation_token: nil) + session[:password_reset_verified_user] = @user.id + session[:password_reset_verified] = Gemcutter::PASSWORD_VERIFICATION_EXPIRY.from_now + end + + def validate_password_reset_session + return login_failure(t("passwords.edit.token_failure")) if session[:password_reset_verified].nil? + return login_failure(t("verification_expired")) if session[:password_reset_verified] < Time.current + @user = User.find_by(id: session[:password_reset_verified_user]) + login_failure(t("verification_expired")) unless @user + end + + def delete_password_reset_session + delete_mfa_session + session.delete(:password_reset_verified_user) + session.delete(:password_reset_verified) end def reset_params - params.fetch(:password_reset, {}).permit(:reset_api_key) + params.permit(password_reset: %i[password reset_api_key reset_api_keys]).require(:password_reset) end - def validate_confirmation_token - @user = find_user_for_edit - redirect_to root_path, alert: t("failure_when_forbidden") unless @user&.valid_confirmation_token? + def mfa_failure(message) + flash.now.alert = message + render template: "multifactor_auths/prompt", status: :unauthorized + end + + def login_failure(alert) + reset_session + redirect_to sign_in_path, alert: + end + + def otp_verification_url + otp_edit_password_url(token: @user.confirmation_token) + end + + def webauthn_verification_url + webauthn_edit_password_url(token: @user.confirmation_token) end end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 8d667897b48..1adbf233817 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -1,24 +1,30 @@ class ProfilesController < ApplicationController + include EmailResettable + before_action :redirect_to_signin, unless: :signed_in?, except: :show + before_action :redirect_to_new_mfa, if: :mfa_required_not_yet_enabled?, except: :show + before_action :redirect_to_settings_strong_mfa_required, if: :mfa_required_weak_level_enabled?, except: :show before_action :verify_password, only: %i[update destroy] - before_action :set_cache_headers, only: :edit + before_action :disable_cache, only: :edit - def edit - @user = current_user + def show + @user = User.find_by_slug!(params[:id]) + @rubygems = @user.rubygems_downloaded.includes(%i[latest_version gem_download]).strict_loading end - def show - @user = User.find_by_slug!(params[:id]) - rubygems = @user.rubygems_downloaded - @rubygems = rubygems.slice!(0, 10) - @extra_rubygems = rubygems + def me + redirect_to profile_path(current_user.display_id) + end + + def edit + @user = current_user end def update @user = current_user.clone if @user.update(params_user) if @user.unconfirmed_email - Delayed::Job.enqueue EmailResetMailer.new(current_user.id) + email_reset(current_user) flash[:notice] = t(".confirmation_mail_sent") else flash[:notice] = t(".updated") @@ -32,26 +38,38 @@ def update def delete @only_owner_gems = current_user.only_owner_gems - @multi_owner_gems = current_user.rubygems_downloaded - @only_owner_gems + @multi_owner_gems = current_user.rubygems_downloaded.to_a - @only_owner_gems end def destroy - Delayed::Job.enqueue DeleteUser.new(current_user), priority: PRIORITIES[:profile_deletion] + DeleteUserJob.perform_later(user: current_user) sign_out redirect_to root_path, notice: t(".request_queued") end + def adoptions + @ownership_calls = current_user.ownership_calls.includes(:user, rubygem: %i[latest_version gem_download]) + @ownership_requests = current_user.ownership_requests.includes(:rubygem) + end + + def security_events + @security_events = current_user.events.order(id: :desc).page(params[:page]).per(50) + render Profiles::SecurityEventsView.new(security_events: @security_events) + end + private def params_user - params.require(:user).permit(:handle, :twitter_username, :unconfirmed_email, :hide_email).tap do |hash| + params.permit(user: PERMITTED_PROFILE_PARAMS).require(:user).tap do |hash| hash.delete(:unconfirmed_email) if hash[:unconfirmed_email] == current_user.email end end + PERMITTED_PROFILE_PARAMS = %i[handle twitter_username unconfirmed_email public_email full_name].freeze + def verify_password - return if current_user.authenticated?(params[:user].delete(:password)) - flash[:notice] = t("profiles.request_denied") - redirect_to edit_profile_path + password = params.permit(user: :password).require(:user)[:password] + return if current_user.authenticated?(password) + redirect_to edit_profile_path, notice: t("profiles.request_denied") end end diff --git a/app/controllers/reverse_dependencies_controller.rb b/app/controllers/reverse_dependencies_controller.rb index 6796f4f122b..706668aeff9 100644 --- a/app/controllers/reverse_dependencies_controller.rb +++ b/app/controllers/reverse_dependencies_controller.rb @@ -10,7 +10,7 @@ def index .by_downloads .preload(:gem_download, :latest_version) - @reverse_dependencies = @reverse_dependencies.legacy_search(params[:rdeps_query]) if params[:rdeps_query]&.is_a?(String) + @reverse_dependencies = @reverse_dependencies.legacy_search(params[:rdeps_query]) if params[:rdeps_query].is_a?(String) @reverse_dependencies = @reverse_dependencies.page(@page).without_count end end diff --git a/app/controllers/rubygems_controller.rb b/app/controllers/rubygems_controller.rb index 96bf1c81743..e5a9ef07d30 100644 --- a/app/controllers/rubygems_controller.rb +++ b/app/controllers/rubygems_controller.rb @@ -1,10 +1,11 @@ class RubygemsController < ApplicationController include LatestVersion - before_action :set_blacklisted_gem, only: :show, if: :blacklisted? - before_action :find_rubygem, only: :show, unless: :blacklisted? - before_action :latest_version, only: :show, unless: :blacklisted? - before_action :find_versioned_links, only: :show, unless: :blacklisted? + before_action :show_reserved_gem, only: %i[show security_events] + before_action :find_rubygem, only: %i[show security_events] + before_action :latest_version, only: %i[show] + before_action :find_versioned_links, only: %i[show] before_action :set_page, only: :index + before_action :redirect_to_signin, unless: :signed_in?, only: %i[security_events] def index respond_to do |format| @@ -13,33 +14,34 @@ def index @gems = Rubygem.letter(@letter).includes(:latest_version, :gem_download).page(@page) end format.atom do - @versions = Version.published(Gemcutter::DEFAULT_PAGINATION) + @versions = Version.published.limit(Gemcutter::DEFAULT_PAGINATION) render "versions/feed" end end end def show - if @blacklisted_gem - render "blacklisted" + @versions = @rubygem.public_versions.limit(5) + @adoption = @rubygem.ownership_call + if @versions.to_a.any? + render "show" else - @versions = @rubygem.public_versions(5) - if @versions.to_a.any? - render "show" - else - render "show_yanked" - end + render "show_yanked" end end - private - - def blacklisted? - (Patterns::GEM_NAME_BLACKLIST.include? params[:id].downcase) + def security_events + authorize @rubygem, :show_events? + @security_events = @rubygem.events.order(id: :desc).page(params[:page]).per(50) + render Rubygems::SecurityEventsView.new(rubygem: @rubygem, security_events: @security_events) end - def set_blacklisted_gem - @blacklisted_gem = params[:id].downcase + private + + def show_reserved_gem + return unless GemNameReservation.reserved?(params[:id]) + @reserved_gem = params[:id].downcase + render "reserved" end def gem_params diff --git a/app/controllers/searches_controller.rb b/app/controllers/searches_controller.rb index f54bbf80320..6cf5b058b32 100644 --- a/app/controllers/searches_controller.rb +++ b/app/controllers/searches_controller.rb @@ -2,9 +2,10 @@ class SearchesController < ApplicationController before_action -> { set_page Gemcutter::SEARCH_MAX_PAGES }, only: :show def show - return unless params[:query]&.is_a?(String) + return unless params[:query].is_a?(String) @error_msg, @gems = ElasticSearcher.new(params[:query], page: @page).search + return unless @gems set_total_pages if @gems.total_count > Gemcutter::SEARCH_MAX_PAGES * Rubygem.default_per_page exact_match = Rubygem.name_is(params[:query]).first @yanked_gem = exact_match unless exact_match&.indexed_versions? diff --git a/app/controllers/sendgrid_events_controller.rb b/app/controllers/sendgrid_events_controller.rb index 38059f46b94..c81868badd7 100644 --- a/app/controllers/sendgrid_events_controller.rb +++ b/app/controllers/sendgrid_events_controller.rb @@ -3,7 +3,7 @@ class SendgridEventsController < ApplicationController # Safelist documented SendGrid Event attributes # https://sendgrid.com/docs/API_Reference/Event_Webhook/event.html#-Event-objects - SENDGRID_EVENT_ATTRIBUTES = %w[ + SENDGRID_EVENT_ATTRIBUTES = %i[ email timestamp smtp-id event category sg_event_id sg_message_id reason status response attempt useragent ip url asm_group_id tls unique_args marketing_campaign_id marketing_campaign_name pool type @@ -12,12 +12,15 @@ class SendgridEventsController < ApplicationController skip_before_action :verify_authenticity_token, only: :create http_basic_authenticate_with( - name: Rails.application.secrets.sendgrid_webhook_username, - password: Rails.application.secrets.sendgrid_webhook_password + name: ENV.fetch("SENDGRID_WEBHOOK_USERNAME", "#{Rails.env}_sendgrid_webhook_user"), + password: ENV.fetch("SENDGRID_WEBHOOK_PASSWORD", "password") ) def create + existing = SendgridEvent.where(sendgrid_id: events_params.pluck(:sg_event_id)).pluck(:sendgrid_id).to_set events_params.each do |event_payload| + next unless existing.add?(event_payload.require(:sg_event_id)) + SendgridEvent.process_later(event_payload) end head :ok @@ -28,6 +31,6 @@ def create def events_params # SendGrid send a JSON array of 1+ events. Each event is a JSON object, see docs: # https://sendgrid.com/docs/for-developers/tracking-events/event/ - params.require(:_json).map { |event_params| event_params.permit(SENDGRID_EVENT_ATTRIBUTES) } + params.permit(_json: SENDGRID_EVENT_ATTRIBUTES).require(:_json) end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index f53fbd75eee..570b02dc4f8 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,56 +1,101 @@ class SessionsController < Clearance::SessionsController - before_action :redirect_to_signin, unless: :signed_in?, only: %i[verify authenticate] + include MfaExpiryMethods + include RequireMfa + include WebauthnVerifiable + include SessionVerifiable + + before_action :redirect_to_signin, unless: :signed_in?, only: %i[verify webauthn_authenticate authenticate] + before_action :redirect_to_new_mfa, if: :mfa_required_not_yet_enabled?, only: %i[verify webauthn_authenticate authenticate] + before_action :redirect_to_settings_strong_mfa_required, if: :mfa_required_weak_level_enabled?, only: %i[verify webauthn_authenticate authenticate] + before_action :webauthn_new_setup, only: :new + + before_action :ensure_not_blocked, only: %i[create] + before_action :find_user, only: %i[create] + before_action :require_mfa, only: %i[create] + before_action :find_mfa_user, only: %i[webauthn_create otp_create] + before_action :validate_otp, only: %i[otp_create] + before_action :validate_webauthn, only: %i[webauthn_create] + after_action :delete_mfa_session, only: %i[webauthn_create webauthn_full_create otp_create] + after_action :delete_session_verification, only: :destroy def create - @user = find_user(params.require(:session)) + do_login(two_factor_label: nil, two_factor_method: nil, authentication_method: "password") + end - if @user&.mfa_enabled? - session[:mfa_user] = @user.display_id - render "sessions/otp_prompt" - else - do_login - end + def webauthn_create + record_mfa_login_duration(mfa_type: "webauthn") + do_login(two_factor_label: user_webauthn_credential.nickname, two_factor_method: "webauthn", authentication_method: "password") end - def mfa_create - @user = User.find_by_slug(session[:mfa_user]) - session.delete(:mfa_user) + def webauthn_full_create + return login_failure(@webauthn_error) unless webauthn_credential_verified? + + @user = user_webauthn_credential.user - if @user&.mfa_enabled? && @user&.otp_verified?(params[:otp]) - do_login + if @user.blocked_email + flash.now.alert = t("sessions.create.account_blocked") + webauthn_new_setup + render template: "sessions/new", status: :unauthorized else - login_failure(t("multifactor_auths.incorrect_otp")) + do_login(two_factor_label: user_webauthn_credential.nickname, two_factor_method: nil, authentication_method: "webauthn") end end + def otp_create + record_mfa_login_duration(mfa_type: @mfa_method) + do_login(two_factor_label: @mfa_label, two_factor_method: @mfa_method, authentication_method: "password") + end + def verify + @user = current_user + setup_webauthn_authentication(form_url: webauthn_authenticate_session_path) end def authenticate + @user = current_user if verify_user - session[:verification] = Time.current + Gemcutter::PASSWORD_VERIFICATION_EXPIRY - redirect_to session.delete(:redirect_uri) || root_path + mark_verified + else + flash.now[:alert] = t("profiles.request_denied") + setup_webauthn_authentication(form_url: webauthn_authenticate_session_path) + render :verify, status: :unauthorized + end + end + + def webauthn_authenticate + @user = current_user + if webauthn_credential_verified? + mark_verified else - flash[:alert] = t("profiles.request_denied") + flash.now[:alert] = @webauthn_error + setup_webauthn_authentication(form_url: webauthn_authenticate_session_path) render :verify, status: :unauthorized end end private + def mark_verified + session_verified + redirect_to session.delete(:redirect_uri) || root_path + end + def verify_user current_user.authenticated? verify_password_params[:password] end def verify_password_params - params.require(:verify_password).permit(:password) + params.permit(verify_password: :password).require(:verify_password) end - def do_login + def do_login(two_factor_label:, two_factor_method:, authentication_method:) sign_in(@user) do |status| if status.success? StatsD.increment "login.success" - redirect_back_or(url_after_create) + current_user.record_event!(Events::UserEvent::LOGIN_SUCCESS, request:, + two_factor_method:, two_factor_label:, authentication_method:) + set_login_flash + redirect_to(url_after_create) else login_failure(status.failure_message) end @@ -60,17 +105,97 @@ def do_login def login_failure(message) StatsD.increment "login.failure" flash.now.notice = message - render template: "sessions/new", status: :unauthorized + webauthn_new_setup + render "sessions/new", status: :unauthorized + end + + def webauthn_failure + invalidate_mfa_session(@webauthn_error) end - def find_user(session) - who = session[:who].is_a?(String) && session.fetch(:who) - password = session[:password].is_a?(String) && session.fetch(:password) + def mfa_failure(message) + login_failure(message) + end + + def find_user + password = params.permit(session: :password).require(:session).fetch(:password, nil) + @user = User.authenticate(who, password) if password.is_a?(String) && who + end - User.authenticate(who, password) if who && password + def find_mfa_user + @user = User.find_by(id: session[:mfa_user]) if mfa_session_active? && session[:mfa_user] + return if @user + delete_mfa_session + login_failure t("multifactor_auths.session_expired") + end + + def who + who_param = params.permit(session: :who).require(:session).fetch(:who, nil) + who_param if who_param.is_a?(String) + end + + def set_login_flash + if current_user.mfa_recommended_not_yet_enabled? + flash[:notice] = t("multifactor_auths.setup_recommended") + elsif current_user.mfa_recommended_weak_level_enabled? + flash[:notice] = t("multifactor_auths.strong_mfa_level_recommended") + elsif !current_user.webauthn_enabled? + flash[:notice_html] = t("multifactor_auths.setup_webauthn_html") + end end def url_after_create - dashboard_path + if current_user.mfa_recommended_not_yet_enabled? + new_totp_path + elsif current_user.mfa_recommended_weak_level_enabled? + edit_settings_path + else + dashboard_path + end + end + + def ensure_not_blocked + user = User.find_by_blocked(who) + return unless user&.blocked_email + + flash.now.alert = t(".account_blocked") + webauthn_new_setup + render template: "sessions/new", status: :unauthorized + end + + def record_mfa_login_duration(mfa_type:) + started_at = Time.zone.parse(session[:mfa_login_started_at]).utc + duration = Time.now.utc - started_at + + StatsD.distribution("login.mfa.#{mfa_type}.duration", duration) + end + + def webauthn_new_setup + @webauthn_options = WebAuthn::Credential.options_for_get( + user_verification: "discouraged" + ) + + @webauthn_verification_url = webauthn_full_create_session_path + + session[:webauthn_authentication] = { + "challenge" => @webauthn_options.challenge + } + end + + def delete_session_verification + session[:verified_user] = session[:verification] = nil + end + + def otp_verification_url + otp_create_session_path + end + + def webauthn_verification_url + webauthn_create_session_path + end + + def incorrect_otp + delete_mfa_session + super end end diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index 0de32161d5e..418c939d554 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -1,8 +1,15 @@ class SettingsController < ApplicationController before_action :redirect_to_signin, unless: :signed_in? - before_action :set_cache_headers + before_action :redirect_to_new_mfa, if: :mfa_required_not_yet_enabled? + before_action :disable_cache def edit @user = current_user + @webauthn_credential = WebauthnCredential.new(user: @user) + @mfa_options = [ + [t(".mfa.level.ui_and_api"), "ui_and_api"], + [t(".mfa.level.ui_and_gem_signin"), "ui_and_gem_signin"] + ] + @mfa_options.insert(2, [t(".mfa.level.ui_only"), "ui_only"]) if @user.mfa_ui_only? end end diff --git a/app/controllers/subscriptions_controller.rb b/app/controllers/subscriptions_controller.rb index c1c9c565eee..67ba4419d61 100644 --- a/app/controllers/subscriptions_controller.rb +++ b/app/controllers/subscriptions_controller.rb @@ -15,6 +15,6 @@ def destroy def redirect_to_rubygem(success) flash[:error] = t("try_again") unless success - redirect_to rubygem_path(@rubygem) + redirect_to rubygem_path(@rubygem.slug) end end diff --git a/app/controllers/totps_controller.rb b/app/controllers/totps_controller.rb new file mode 100644 index 00000000000..1abb78414ec --- /dev/null +++ b/app/controllers/totps_controller.rb @@ -0,0 +1,80 @@ +class TotpsController < ApplicationController + include MfaExpiryMethods + include RequireMfa + include WebauthnVerifiable + + before_action :redirect_to_signin, unless: :signed_in? + before_action :require_totp_disabled, only: %i[new create] + before_action :require_totp_enabled, only: :destroy + before_action :seed_and_expire, only: :create + before_action :disable_cache, only: %i[new] + helper_method :issuer + + def new + @seed = ROTP::Base32.random_base32 + session[:totp_seed] = @seed + session[:totp_seed_expire] = Gemcutter::MFA_KEY_EXPIRY.from_now.utc.to_i + text = ROTP::TOTP.new(@seed, issuer: issuer).provisioning_uri(current_user.email) + @qrcode_svg = RQRCode::QRCode.new(text, level: :l).as_svg(module_size: 6).html_safe # rubocop:disable Rails/OutputSafety + end + + def create + current_user.verify_and_enable_totp!(@seed, :ui_and_api, otp_param, @expire) + if current_user.errors.any? + flash[:error] = current_user.errors[:base].join + redirect_to edit_settings_url + else + flash[:success] = t(".success") + @continue_path = session.fetch("mfa_redirect_uri", edit_settings_path) + + if current_user.mfa_device_count_one? + session[:show_recovery_codes] = current_user.new_mfa_recovery_codes + redirect_to recovery_multifactor_auth_path + else + redirect_to @continue_path + session.delete("mfa_redirect_uri") + end + end + end + + def destroy + if current_user.ui_mfa_verified?(otp_param) + flash[:success] = t(".success") + current_user.disable_totp! + redirect_to session.fetch("mfa_redirect_uri", edit_settings_path) + session.delete("mfa_redirect_uri") + else + flash[:error] = t("totps.incorrect_otp") + redirect_to edit_settings_path + end + end + + private + + def otp_param + params.permit(:otp).fetch(:otp, "") + end + + def issuer + request.host || "rubygems.org" + end + + def require_totp_disabled + return if current_user.totp_disabled? + flash[:error] = t("totps.require_totp_disabled", host: Gemcutter::HOST_DISPLAY) + redirect_to edit_settings_path + end + + def require_totp_enabled + return if current_user.totp_enabled? + + flash[:error] = t("totps.require_totp_enabled") + delete_mfa_session + redirect_to edit_settings_path + end + + def seed_and_expire + @seed = session.delete(:totp_seed) + @expire = Time.at(session.delete(:totp_seed_expire) || 0).utc + end +end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 4723a54e7f7..243e4a6e231 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,14 +1,16 @@ -class UsersController < Clearance::UsersController +class UsersController < ApplicationController + before_action :redirect_to_root, if: :signed_in? + def new - @user = user_from_params + @user = User.new end def create - @user = user_from_params + @user = User.new(user_params) if @user.save - Delayed::Job.enqueue EmailConfirmationMailer.new(@user.id) + Mailer.email_confirmation(@user).deliver_later flash[:notice] = t(".email_sent") - redirect_back_or url_after_create + redirect_back_or_to root_path else render template: "users/new" end @@ -16,7 +18,19 @@ def create private + PERMITTED_USER_PARAMS = %i[ + bio + email + handle + public_email + location + password + website + twitter_username + full_name + ].freeze + def user_params - params.permit(user: Array(User::PERMITTED_ATTRS)).fetch(:user, {}) + params.permit(user: PERMITTED_USER_PARAMS).require(:user) end end diff --git a/app/controllers/versions_controller.rb b/app/controllers/versions_controller.rb index 23269c983ab..a2ce84c89f1 100644 --- a/app/controllers/versions_controller.rb +++ b/app/controllers/versions_controller.rb @@ -2,13 +2,16 @@ class VersionsController < ApplicationController before_action :find_rubygem def index - @versions = @rubygem.versions.by_position + set_page + @versions = @rubygem.versions.by_position.page(@page).per(Gemcutter::VERSIONS_PER_PAGE) end def show - @latest_version = Version.find_from_slug!(@rubygem.id, params[:id]) - @versions = @rubygem.public_versions_with_extra_version(@latest_version) + @latest_version = @rubygem.find_version_by_slug!(params.permit(:id).require(:id)) + @versions = @rubygem.public_versions_with_extra_version(@latest_version) @versioned_links = @rubygem.links(@latest_version) + @adoption = @rubygem.ownership_call + @on_version_page = true render "rubygems/show" end end diff --git a/app/controllers/webauthn_credentials_controller.rb b/app/controllers/webauthn_credentials_controller.rb new file mode 100644 index 00000000000..cba1128454a --- /dev/null +++ b/app/controllers/webauthn_credentials_controller.rb @@ -0,0 +1,66 @@ +class WebauthnCredentialsController < ApplicationController + before_action :redirect_to_signin, unless: :signed_in? + + def create + @create_options = current_user.webauthn_options_for_create + + session[:webauthn_registration] = { "challenge" => @create_options.challenge } + + render json: @create_options + end + + def callback + webauthn_credential = build_webauthn_credential + + if webauthn_credential.save + flash[:notice] = t(".success") + render_callback_redirect + else + message = webauthn_credential.errors.full_messages.to_sentence + render json: { message: message }, status: :unprocessable_entity + end + rescue WebAuthn::Error => e + render json: { message: e.message }, status: :unprocessable_entity + ensure + session.delete("webauthn_registration") + end + + def destroy + webauthn_credential = current_user.webauthn_credentials.find(params[:id]) + if webauthn_credential.destroy + flash[:notice] = t(".webauthn_credential.confirm_delete") + else + flash[:error] = webauthn_credential.errors.full_messages.to_sentence + end + + redirect_to edit_settings_path + end + + private + + def webauthn_credential_params + params.permit(webauthn_credential: :nickname).require(:webauthn_credential) + end + + def build_webauthn_credential + credential = WebAuthn::Credential.from_create(params.permit(credentials: {}).require(:credentials)) + credential.verify(session.dig(:webauthn_registration, "challenge").to_s) + + current_user.webauthn_credentials.build( + webauthn_credential_params.merge( + external_id: credential.id, + public_key: credential.public_key, + sign_count: credential.sign_count + ) + ) + end + + def render_callback_redirect + if current_user.mfa_device_count_one? + session[:show_recovery_codes] = current_user.new_mfa_recovery_codes + render json: { redirect_url: recovery_multifactor_auth_url } + else + render json: { redirect_url: edit_settings_url } + end + end +end diff --git a/app/controllers/webauthn_verifications_controller.rb b/app/controllers/webauthn_verifications_controller.rb new file mode 100644 index 00000000000..14f93cbff9b --- /dev/null +++ b/app/controllers/webauthn_verifications_controller.rb @@ -0,0 +1,70 @@ +# This controller is for the user interface Webauthn challenge after a user follows a link generated +# by the APIv1 WebauthnVerificationsController (controllers/api/v1/webauthn_verifications_controller). +class WebauthnVerificationsController < ApplicationController + include WebauthnVerifiable + + before_action :set_verification_status_session, only: :authenticate + before_action :set_verification, :set_user, except: %i[successful_verification failed_verification] + before_action :check_show_verification_status, only: %i[successful_verification failed_verification] + + def prompt + redirect_to root_path, alert: t(".no_port") unless (port = params[:port]) + redirect_to root_path, alert: t(".no_webauthn_devices") if @user.webauthn_credentials.blank? + + setup_webauthn_authentication(form_url: authenticate_webauthn_verification_path, session_options: { "port" => port }) + end + + def authenticate + port = session.dig(:webauthn_authentication, "port") + + unless port + redirect_to root_path, alert: t(".no_port") + return + end + + return render plain: @webauthn_error, status: :unauthorized unless webauthn_credential_verified? + + @verification.generate_otp + @verification.expire_path_token + + return render plain: "success" if browser.safari? + redirect_to(URI.parse("http://localhost:#{port}?code=#{@verification.otp}").to_s, allow_other_host: true) + end + + def failed_verification + @message = params.permit(:error).fetch(:error, "") + logger.info("WebAuthn Verification failed", error: @message) + end + + private + + def set_verification_status_session + session[:show_webauthn_status] = true + end + + def set_verification + @verification = WebauthnVerification.find_by(path_token: webauthn_token_param) + + render_not_found and return unless @verification + render_expired if @verification.path_token_expired? + end + + def set_user + @user = @verification.user + end + + def check_show_verification_status + render_not_found unless session.delete(:show_webauthn_status) + end + + def webauthn_token_param + params.permit(:webauthn_token).require(:webauthn_token) + end + + def render_expired + respond_to do |format| + format.html { redirect_to root_path, alert: t("webauthn_verifications.expired_or_already_used") } + format.text { render plain: t("webauthn_verifications.expired_or_already_used"), status: :unauthorized } + end + end +end diff --git a/app/helpers/api_keys_helper.rb b/app/helpers/api_keys_helper.rb new file mode 100644 index 00000000000..c64f55f3e0a --- /dev/null +++ b/app/helpers/api_keys_helper.rb @@ -0,0 +1,46 @@ +module ApiKeysHelper + def gem_scope(api_key) + return invalid_gem_tooltip(api_key.soft_deleted_rubygem_name) if api_key.soft_deleted_by_ownership? + + api_key.rubygem ? api_key.rubygem.name : t("api_keys.all_gems") + end + + def api_key_checkbox(form, api_scope) + exclusive = ApiKey::EXCLUSIVE_SCOPES.include?(api_scope) + gem_scope = ApiKey::APPLICABLE_GEM_API_SCOPES.include?(api_scope) + + data = {} + data[:exclusive_checkbox_target] = exclusive ? "exclusive" : "inclusive" + data[:gem_scope_target] = "checkbox" if gem_scope + + html_options = { class: "form__checkbox__input", id: api_scope, data: } + form.check_box api_scope, html_options, "true", "false" + end + + def self.api_key_params(params, existing_api_key = nil) + scopes = params.fetch(:scopes, existing_api_key&.scopes || []).to_set + boolean = ActiveRecord::Type::Boolean.new + ApiKey::API_SCOPES.each do |scope| + next unless params.key?(scope) + + if boolean.cast(params.delete(scope)) + scopes << scope + else + scopes.delete(scope) + end + end + params[:scopes] = scopes.sort + params + end + + private + + def invalid_gem_tooltip(name) + content_tag( + :span, + "#{name} [?]", + class: "tooltip__text", + data: { tooltip: t("api_keys.gem_ownership_removed", rubygem_name: name) } + ) + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 6222968a5f9..c2dac569d41 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,14 +1,25 @@ module ApplicationHelper + include BetterHtml::Helpers + def page_title combo = "#{t :title} | #{t :subtitle}" + # If instance variable @title_for_header_only is present then it is added to combo title string + combo = "#{@title_for_header_only} | #{combo}" if @title_for_header_only.present? @title.present? ? "#{@title} | #{combo}" : combo end def atom_feed_link(title, url) - tag "link", rel: "alternate", + tag.link(rel: "alternate", type: "application/atom+xml", href: url, - title: title + title: title) + end + + # Copied from importmap-rails but with the nonce removed. We rely on the sha256 hash instead. + # Relying on the hash improves the caching behavior by not sending the cached nonce to the client. + def javascript_inline_importmap_tag(importmap_json = Rails.application.importmap.to_json(resolver: self)) + tag.script importmap_json.html_safe, + type: "importmap", "data-turbo-track": "reload" end def short_info(rubygem) @@ -18,18 +29,22 @@ def short_info(rubygem) def gem_info(rubygem) if rubygem.respond_to?(:description) - [rubygem.description, rubygem.summary, "This rubygem does not have a description or summary."].find(&:present?) + [rubygem.summary, rubygem.description, "This rubygem does not have a description or summary."].find(&:present?) else version = rubygem.latest_version || rubygem.versions.last version.info end end - def gravatar(size, id = "gravatar", user = current_user) - image_tag user.gravatar_url(size: size, secure: request.ssl?).html_safe, + def avatar(size, id = "gravatar", user = current_user, theme: :light, **) + raise ArgumentError, "invalid default avatar theme, only light and dark are suported" unless %i[light dark].include? theme + + url = avatar_user_path(user.id, params: { size: size, theme: theme }) + image_tag(url, id: id, width: size, - height: size + height: size, + **) end def download_count(rubygem) @@ -40,14 +55,6 @@ def stats_graph_meter(gem, count) gem.downloads * 1.0 / count * 100 end - def search_form_class - if [root_path, advanced_search_path].include? request.path_info - "header__search-wrap--home" - else - "header__search-wrap" - end - end - def active?(path) "is-active" if request.path_info == path end @@ -63,7 +70,36 @@ def content_for_title(title, title_url) link_to title, title_url, class: "t-link--black" end - def i18n_api_scopes(api_key) - api_key.enabled_scopes.map { |scope| content_tag(:ul, t(".#{scope}"), class: "scopes__list") }.reduce(&:+) + def flash_message(name, msg) + return sanitize(msg) if name.end_with? "html" + msg + end + + def rubygem_search_field(home: false) + data = { + autocomplete_target: "query", + action: %w[ + autocomplete#suggest + keydown.down->autocomplete#next + keydown.up->autocomplete#prev + keydown.esc->autocomplete#hide + keydown.enter->autocomplete#clear + click@window->autocomplete#hide + focus->autocomplete#suggest + blur->autocomplete#hide + ].join(" ") + } + data[:nav_target] = "search" unless home + + search_field_tag( + :query, + params[:query], + placeholder: t("layouts.application.header.search_gem_html"), + autofocus: current_page?(root_url), + class: home ? "home__search" : "header__search", + autocomplete: "off", + aria: { autocomplete: "list" }, + data: data + ) end end diff --git a/app/helpers/duration_helper.rb b/app/helpers/duration_helper.rb new file mode 100644 index 00000000000..c341476ecfb --- /dev/null +++ b/app/helpers/duration_helper.rb @@ -0,0 +1,10 @@ +module DurationHelper + def duration_string(duration) + parts = duration.parts + parts = { seconds: duration.value } if parts.empty? + + to_sentence(parts + .sort_by { |unit, _| ActiveSupport::Duration::PARTS.index(unit) } + .map { |unit, val| t("duration.#{unit}", count: val) }) + end +end diff --git a/app/helpers/dynamic_errors_helper.rb b/app/helpers/dynamic_errors_helper.rb index 26b46bf2d08..46098b81d39 100644 --- a/app/helpers/dynamic_errors_helper.rb +++ b/app/helpers/dynamic_errors_helper.rb @@ -40,7 +40,7 @@ def error_messages_for(*params) message = options.include?(:message) ? options[:message] : locale.t(:body) - error_messages = objects.sum do |object| + error_messages = objects.map do |object| object.errors.full_messages.map do |msg| content_tag(:li, msg) end diff --git a/app/helpers/mailer_helper.rb b/app/helpers/mailer_helper.rb new file mode 100644 index 00000000000..1b5e8d4eedc --- /dev/null +++ b/app/helpers/mailer_helper.rb @@ -0,0 +1,37 @@ +module MailerHelper + def mfa_required_soon_subject(mfa_level) + case mfa_level + when "disabled" + "[Action Required] Enable multi-factor authentication on your RubyGems account by August 15" + when "ui_only" + "[Action Required] Upgrade the multi-factor authentication level on your RubyGems account by August 15" + end + end + + def mfa_required_soon_heading(mfa_level) + case mfa_level + when "disabled" + "Enable multi-factor authentication on your RubyGems account" + when "ui_only" + "Upgrade the multi-factor authentication level on your RubyGems account" + end + end + + def mfa_required_popular_gems_subject(mfa_level) + case mfa_level + when "disabled" + "[Action Required] Enabling multi-factor authentication is required on your RubyGems account" + when "ui_only" + "[Action Required] Upgrading the multi-factor authentication level is required on your RubyGems account" + end + end + + def mfa_required_popular_gems_heading(mfa_level) + case mfa_level + when "disabled" + "Enable multi-factor authentication on your RubyGems account" + when "ui_only" + "Upgrade the multi-factor authentication level on your RubyGems account" + end + end +end diff --git a/app/helpers/oidc/api_key_roles_helper.rb b/app/helpers/oidc/api_key_roles_helper.rb new file mode 100644 index 00000000000..4af601f03db --- /dev/null +++ b/app/helpers/oidc/api_key_roles_helper.rb @@ -0,0 +1,2 @@ +module OIDC::ApiKeyRolesHelper +end diff --git a/app/helpers/oidc/providers_helper.rb b/app/helpers/oidc/providers_helper.rb new file mode 100644 index 00000000000..ade07a70c24 --- /dev/null +++ b/app/helpers/oidc/providers_helper.rb @@ -0,0 +1,2 @@ +module OIDC::ProvidersHelper +end diff --git a/app/helpers/owners_helper.rb b/app/helpers/owners_helper.rb index 7cb74e73aa2..a9fcfc214fc 100644 --- a/app/helpers/owners_helper.rb +++ b/app/helpers/owners_helper.rb @@ -18,4 +18,10 @@ def mfa_status(user) image_tag("/images/check.svg") end end + + def sanitize_note(text) + options = RDoc::Options.new + options.pipe = true + simple_format RDoc::Markup.new.convert(text, RDoc::Markup::ToHtml.new(options)) + end end diff --git a/app/helpers/pages_helper.rb b/app/helpers/pages_helper.rb index 49bf35a81d0..5c303f9476c 100644 --- a/app/helpers/pages_helper.rb +++ b/app/helpers/pages_helper.rb @@ -9,7 +9,7 @@ def version def subtitle subtitle = "v#{version_number}" - subtitle += " - #{nice_date_for(version.created_at)}" + subtitle += " - #{nice_date_for(version.authored_at)}" if version subtitle end end diff --git a/app/helpers/rubygems_helper.rb b/app/helpers/rubygems_helper.rb index dda96406d70..a6f2db4279c 100644 --- a/app/helpers/rubygems_helper.rb +++ b/app/helpers/rubygems_helper.rb @@ -11,8 +11,10 @@ def formatted_licenses(license_names) end end - def link_to_page(id, url) - link_to(t("rubygems.aside.links.#{id}"), url, rel: "nofollow", class: %w[gem__link t-list__item], id: id) if url.present? + def link_to_page(id, url, verified: false) + classes = %w[gem__link t-list__item] + classes << "gem__link__verified" if verified + link_to(t("rubygems.aside.links.#{id}"), url, rel: "nofollow", class: classes, id: id) if url.present? end def link_to_directory @@ -27,18 +29,18 @@ def simple_markup(text) options.pipe = true sanitize RDoc::Markup.new.convert(text, RDoc::Markup::ToHtml.new(options)) else - content_tag :p, escape_once(sanitize(text.strip)), nil, false + tag.p(escape_once(sanitize(text.strip))) end end def subscribe_link(rubygem) if signed_in? if rubygem.subscribers.find_by_id(current_user.id) - link_to t(".links.unsubscribe"), rubygem_subscription_path(rubygem), + link_to t(".links.unsubscribe"), rubygem_subscription_path(rubygem.slug), class: [:toggler, "gem__link", "t-list__item"], id: "unsubscribe", method: :delete else - link_to t(".links.subscribe"), rubygem_subscription_path(rubygem), + link_to t(".links.subscribe"), rubygem_subscription_path(rubygem.slug), class: %w[toggler gem__link t-list__item], id: "subscribe", method: :post end @@ -52,7 +54,7 @@ def unsubscribe_link(rubygem) return unless signed_in? style = "t-item--hidden" unless rubygem.subscribers.find_by_id(current_user.id) - link_to t(".links.unsubscribe"), rubygem_subscription_path(rubygem), + link_to t("rubygems.aside.links.unsubscribe"), rubygem_subscription_path(rubygem.slug), class: [:toggler, "gem__link", "t-list__item", style], id: "unsubscribe", method: :delete, remote: true end @@ -67,12 +69,12 @@ def change_diff_link(rubygem, latest_version) end def atom_link(rubygem) - link_to t(".links.rss"), rubygem_versions_path(rubygem, format: "atom"), + link_to t(".links.rss"), rubygem_versions_path(rubygem.slug, format: "atom"), class: "gem__link t-list__item", id: :rss end def reverse_dependencies_link(rubygem) - link_to_page :reverse_dependencies, rubygem_reverse_dependencies_path(rubygem) + link_to_page :reverse_dependencies, rubygem_reverse_dependencies_path(rubygem.slug) end def badge_link(rubygem) @@ -83,17 +85,50 @@ def badge_link(rubygem) def report_abuse_link(rubygem) subject = "Reporting Abuse on #{rubygem.name}" report_abuse_url = "mailto:support@rubygems.org" \ - "?subject=" + subject + "?subject=" + subject link_to t("rubygems.aside.links.report_abuse"), report_abuse_url.html_safe, class: "gem__link t-list__item" end def ownership_link(rubygem) - link_to I18n.t("rubygems.aside.links.ownership"), rubygem_owners_path(rubygem), class: "gem__link t-list__item" + link_to I18n.t("rubygems.aside.links.ownership"), rubygem_owners_path(rubygem.slug), class: "gem__link t-list__item" + end + + def rubygem_trusted_publishers_link(rubygem) + link_to t("rubygems.aside.links.trusted_publishers"), rubygem_trusted_publishers_path(rubygem.slug), class: "gem__link t-list__item" + end + + def oidc_api_key_role_links(rubygem) + roles = current_user.oidc_api_key_roles.for_rubygem(rubygem) + + links = roles.map do |role| + link_to( + t("rubygems.aside.links.oidc.api_key_role.name", name: role.name), + profile_oidc_api_key_role_path(role.token), + class: "gem__link t-list__item" + ) + end + links << link_to( + t("rubygems.aside.links.oidc.api_key_role.new"), + new_profile_oidc_api_key_role_path(rubygem: rubygem.name, scopes: ["push_rubygem"]), + class: "gem__link t-list__item" + ) + + safe_join(links) end def resend_owner_confirmation_link(rubygem) link_to I18n.t("rubygems.aside.links.resend_ownership_confirmation"), - resend_confirmation_rubygem_owners_path(rubygem), class: "gem__link t-list__item" + resend_confirmation_rubygem_owners_path(rubygem.slug), class: "gem__link t-list__item" + end + + def rubygem_adoptions_link(rubygem) + link_to "Adoption", + rubygem_adoptions_path(rubygem.slug), class: "gem__link t-list__item" + end + + def rubygem_security_events_link(rubygem) + link_to "Security Events", + security_events_rubygem_path(rubygem.slug), class: "gem__link t-list__item" end def links_to_owners(rubygem) @@ -105,12 +140,21 @@ def links_to_owners_without_mfa(rubygem) end def link_to_user(user) - link_to gravatar(48, "gravatar-#{user.id}", user), profile_path(user.display_id), + link_to avatar(48, "gravatar-#{user.id}", user), profile_path(user.display_id), alt: user.display_handle, title: user.display_handle end + def link_to_pusher(api_key_owner) + case api_key_owner + when OIDC::TrustedPublisher::GitHubAction + image_tag "github_icon.png", width: 48, height: 48, theme: :light, alt: "GitHub", title: api_key_owner.name + else + raise ArgumentError, "unknown api_key_owner type #{api_key_owner.class}" + end + end + def nice_date_for(time) - time.to_date.to_formatted_s(:long) + time.to_date.to_fs(:long) end def show_all_versions_link?(rubygem) @@ -123,10 +167,10 @@ def latest_version_number(rubygem) end def link_to_github(rubygem) - if rubygem.links.source_code_uri.present? && URI(rubygem.links.source_code_uri).host == "github.com" - URI(rubygem.links.source_code_uri) - elsif rubygem.links.homepage_uri.present? && URI(rubygem.links.homepage_uri).host == "github.com" - URI(rubygem.links.homepage_uri) + candidates = [rubygem.links.source_code_uri, rubygem.links.homepage_uri].compact + + candidates.lazy.filter_map { |link| URI(link) }.find do |link_uri| + link_uri.host == "github.com" end rescue URI::InvalidURIError nil @@ -134,6 +178,44 @@ def link_to_github(rubygem) def github_params(rubygem) link = link_to_github(rubygem) - "user=#{link.path.split('/').second}&repo=#{link.path.split('/').third}&type=star&count=true&size=large" if link + return unless link + + { + user: link.path.split("/").second, + repo: link.path.split("/").third, + type: "star", + count: "true", + size: "large" + } + end + + def copy_field_tag(name, value) + field = text_field_tag( + name, + value, + id: name, + class: "gem__code", + readonly: "readonly", + data: { clipboard_target: "source" } + ) + + button = tag.span( + "=", + class: "gem__code__icon", + title: t("copy_to_clipboard"), + data: { + action: "click->clipboard#copy", + clipboard_target: "button" + } + ) + + tag.div( + field + button, + class: "gem__code-wrap", + data: { + controller: "clipboard", + clipboard_success_content_value: "✔" + } + ) end end diff --git a/app/helpers/searches_helper.rb b/app/helpers/searches_helper.rb index b518a2d181e..1be755cd15d 100644 --- a/app/helpers/searches_helper.rb +++ b/app/helpers/searches_helper.rb @@ -6,7 +6,7 @@ def es_suggestions(gems) return false if suggestions.blank? return false if suggestions["suggest_name"].blank? return false if suggestions["suggest_name"][0]["options"].empty? - suggestions.map { |_k, v| v.first["options"] }.flatten.map { |v| v["text"] }.uniq + suggestions.map { |_k, v| v.first["options"] }.flatten.pluck("text").uniq end def aggregation_match_count(aggregration, field) @@ -21,7 +21,7 @@ def aggregation_count(aggregration, duration, buckets_pos) count = aggregration["buckets"][buckets_pos]["doc_count"] return unless count > 0 - time_ago = (Time.zone.today - duration).to_s(:db) + time_ago = (Time.zone.today - duration).to_fs(:db) path = search_path(params: { query: "#{params[:query]} AND updated:[#{time_ago} TO *}" }) update_info = (duration == 30.days ? t("searches.show.month_update", count: count) : t("searches.show.week_update", count: count)) link_to update_info, path, class: "t-link--black" @@ -35,6 +35,6 @@ def aggregation_yanked(yanked_gem) end def not_empty?(response) - response["hits"]["total"] != 0 + response["hits"]["total"]["value"] != 0 end end diff --git a/app/helpers/versions_helper.rb b/app/helpers/versions_helper.rb new file mode 100644 index 00000000000..4545f432500 --- /dev/null +++ b/app/helpers/versions_helper.rb @@ -0,0 +1,20 @@ +module VersionsHelper + def version_date_tag(version) + data = {} + klass = ["gem__version__date"] + date = version_authored_date(version) + if version.rely_on_built_at? + klass << "tooltip__text" + data.merge!(tooltip: t("versions.index.imported_gem_version_notice", import_date: nice_date_for(Version::RUBYGEMS_IMPORT_DATE))) + end + + content_tag(:small, class: klass, data: data) do + concat date + concat content_tag(:sup, "*") if version.rely_on_built_at? + end + end + + def version_authored_date(version) + "- #{nice_date_for(version.authored_at)}" + end +end diff --git a/app/javascript/application.js b/app/javascript/application.js new file mode 100644 index 00000000000..3623739686b --- /dev/null +++ b/app/javascript/application.js @@ -0,0 +1,14 @@ +// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails +import Rails from "@rails/ujs"; +Rails.start(); + +import LocalTime from "local-time" +LocalTime.start() + +import "controllers" + +import "src/oidc_api_key_role_form"; +import "src/pages"; +import "src/transitive_dependencies"; +import "src/webauthn"; +import "github-buttons"; diff --git a/app/javascript/avo.custom.js b/app/javascript/avo.custom.js new file mode 100644 index 00000000000..fc330d184c9 --- /dev/null +++ b/app/javascript/avo.custom.js @@ -0,0 +1,4 @@ +import { application } from "controllers/application" + +import NestedForm from "stimulus-rails-nested-form"; +application.register("nested-form", NestedForm); diff --git a/app/javascript/controllers/application.js b/app/javascript/controllers/application.js new file mode 100644 index 00000000000..c3847f16889 --- /dev/null +++ b/app/javascript/controllers/application.js @@ -0,0 +1,13 @@ +import { Application } from "@hotwired/stimulus" +import Clipboard from '@stimulus-components/clipboard' + +const application = Application.start() + +// Add vendored controllers +application.register('clipboard', Clipboard) + +// Configure Stimulus development experience +application.debug = false +window.Stimulus = application + +export { application } diff --git a/app/javascript/controllers/autocomplete_controller.js b/app/javascript/controllers/autocomplete_controller.js new file mode 100644 index 00000000000..a840f4eb1d1 --- /dev/null +++ b/app/javascript/controllers/autocomplete_controller.js @@ -0,0 +1,103 @@ +import { Controller } from "@hotwired/stimulus" + +// TODO: Add suggest help text and aria-live +// https://accessibility.huit.harvard.edu/technique-aria-autocomplete +export default class extends Controller { + static targets = ["query", "suggestions", "template", "item"] + static classes = ["selected"] + + connect() { + this.indexNumber = -1; + this.suggestLength = 0; + } + + disconnect() { clear() } + + clear() { + this.suggestionsTarget.innerHTML = "" + this.suggestionsTarget.removeAttribute('tabindex'); + this.suggestionsTarget.removeAttribute('aria-activedescendant'); + } + + hide(e) { + // Allows adjusting the cursor in the input without hiding the suggestions. + if (!this.queryTarget.contains(e.target)) this.clear() + } + + next() { + this.indexNumber++; + if (this.indexNumber >= this.suggestLength) this.indexNumber = 0; + this.focusItem(this.itemTargets[this.indexNumber]); + } + + prev() { + this.indexNumber--; + if (this.indexNumber < 0) this.indexNumber = this.suggestLength - 1; + this.focusItem(this.itemTargets[this.indexNumber]); + } + + // On mouseover, highlight the item, shifting the index, + // but don't change the input because it causes an undesireable feedback loop. + highlight(e) { + this.indexNumber = this.itemTargets.indexOf(e.currentTarget) + this.focusItem(e.currentTarget, false) + } + + choose(e) { + this.clear(); + this.queryTarget.value = e.target.textContent; + this.queryTarget.form.submit(); + } + + async suggest(e) { + const el = e.currentTarget; + const term = el.value.trim(); + + if (term.length >= 2) { + el.classList.remove('autocomplete-done'); + el.classList.add('autocomplete-loading'); + const query = new URLSearchParams({ query: term }) + + try { + const response = await fetch('/api/v1/search/autocomplete?' + query, { method: 'GET' }) + const data = await response.json() + this.showSuggestions(data.slice(0, 10)) + } catch (error) { } + el.classList.remove('autocomplete-loading'); + el.classList.add('autocomplete-done'); + } else { + this.clear() + } + } + + showSuggestions(items) { + this.clear(); + if (items.length === 0) { + return; + } + items.forEach((item, idx) => this.appendItem(item, idx)); + this.suggestionsTarget.setAttribute('tabindex', 0); + this.suggestionsTarget.setAttribute('role', 'listbox'); + + this.suggestLength = items.length; + this.indexNumber = -1; + }; + + appendItem(text, idx) { + const clone = this.templateTarget.content.cloneNode(true); + const li = clone.querySelector('li') + li.textContent = text; + li.id = `suggest-${idx}`; + this.suggestionsTarget.appendChild(clone) + } + + focusItem(el, change = true) { + this.itemTargets.forEach(el => el.classList.remove(this.selectedClass)) + el.classList.add(this.selectedClass); + this.suggestionsTarget.setAttribute('aria-activedescendant', el.id); + if (change) { + this.queryTarget.value = el.textContent; + this.queryTarget.focus(); + } + } +} diff --git a/app/javascript/controllers/dropdown_controller.js b/app/javascript/controllers/dropdown_controller.js new file mode 100644 index 00000000000..16e4c999ae3 --- /dev/null +++ b/app/javascript/controllers/dropdown_controller.js @@ -0,0 +1,18 @@ +import { Controller } from "@hotwired/stimulus" + +// I tested the stimulus-dropdown component but it has too many deps. +// This mimics the basic stimulus-dropdown api, so we could swap it in later. +export default class extends Controller { + static targets = ["menu"] + + hide(e) { + !this.element.contains(e.target) && + !this.menuTarget.classList.contains("hidden") && + this.menuTarget.classList.add('hidden'); + } + + toggle(e) { + e.preventDefault(); + this.menuTarget.classList.toggle('hidden'); + } +} diff --git a/app/javascript/controllers/dump_controller.js b/app/javascript/controllers/dump_controller.js new file mode 100644 index 00000000000..aecd608354e --- /dev/null +++ b/app/javascript/controllers/dump_controller.js @@ -0,0 +1,62 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["list", "template"] + + connect() { + this.getDumpData(); + } + + getDumpData() { + fetch('https://s3-us-west-2.amazonaws.com/rubygems-dumps/?prefix=production/public_postgresql') + .then(response => response.text()) + .then(data => { + const parser = new DOMParser(); + const xml = parser.parseFromString(data, "application/xml"); + const files = this.parseS3Listing(xml); + this.render(files); + }) + .catch(error => { + console.error(error); + }); + } + + parseS3Listing(xml) { + const contents = Array.from(xml.getElementsByTagName('Contents')); + return contents.map(item => { + return { + Key: item.getElementsByTagName('Key')[0].textContent, + LastModified: item.getElementsByTagName('LastModified')[0].textContent, + Size: item.getElementsByTagName('Size')[0].textContent, + StorageClass: item.getElementsByTagName('StorageClass')[0].textContent + }; + }); + } + + render(files) { + files + .filter(item => 'STANDARD' === item.StorageClass) + .sort((a, b) => Date.parse(b.LastModified) - Date.parse(a.LastModified)) + .forEach(item => { + let text = `${item.LastModified.replace('.000Z', '')} (${this.bytesToSize(item.Size)})`; + let uri = `https://s3-us-west-2.amazonaws.com/rubygems-dumps/${item.Key}`; + this.appendItem(text, uri); + }); + } + + appendItem(text, uri) { + const clone = this.templateTarget.content.cloneNode(true); + const a = clone.querySelector('a') + a.textContent = text; + a.href = uri; + this.element.appendChild(clone) + } + + bytesToSize(bytes) { + if (bytes === 0) { return '0 Bytes' } + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return (bytes / Math.pow(k, i)).toPrecision(3) + " " + sizes[i]; + } +} diff --git a/app/javascript/controllers/exclusive_checkbox_controller.js b/app/javascript/controllers/exclusive_checkbox_controller.js new file mode 100644 index 00000000000..da1266eab2d --- /dev/null +++ b/app/javascript/controllers/exclusive_checkbox_controller.js @@ -0,0 +1,35 @@ +import { Controller } from "@hotwired/stimulus" + +// Supports one exclusive target, but many inclusive targets +export default class extends Controller { + static targets = ["inclusive", "exclusive"] + + connect() { + // Unselect all inclusive targets if the exclusive target is selected on load + this.updateExclusive() + } + + exclusiveTargetConnected(el) { + el.addEventListener("change", () => this.updateExclusive()) + } + + inclusiveTargetConnected(el) { + el.addEventListener("change", (e) => { + if (e.currentTarget.checked) { this.uncheck(this.exclusiveTarget) } + }) + } + + updateExclusive() { + if (this.exclusiveTarget.checked) { + this.inclusiveTargets.forEach(this.uncheck) + } + } + + uncheck(checkbox) { + if (checkbox.checked) { + checkbox.checked = false + checkbox.dispatchEvent(new Event("change")) + } + } +} + diff --git a/app/javascript/controllers/gem_scope_controller.js b/app/javascript/controllers/gem_scope_controller.js new file mode 100644 index 00000000000..e9ca7ad2ac8 --- /dev/null +++ b/app/javascript/controllers/gem_scope_controller.js @@ -0,0 +1,43 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["checkbox", "selector"] + + connect() { + this.toggleSelector() + } + + checkboxTargetConnected(el) { + el.addEventListener("change", () => this.toggleSelector()) + } + + toggleSelector() { + const selected = this.checkboxTargets.find((target) => target.checked) + + if (selected) { + this.selectorTarget.disabled = false; + this.removeHiddenRubygemField(); + } else { + this.selectorTarget.value = ""; + this.selectorTarget.disabled = true; + this.addHiddenRubygemField(); + } + } + + addHiddenRubygemField() { + if (this.hiddenField) { return } + this.hiddenField = document.createElement("input"); + this.hiddenField.type = "hidden"; + this.hiddenField.name = "api_key[rubygem_id]"; + this.hiddenField.value = ""; + this.element.appendChild(this.hiddenField); + } + + removeHiddenRubygemField() { + if (this.hiddenField) { + this.hiddenField.remove(); + this.hiddenField = null; + } + } +} + diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js new file mode 100644 index 00000000000..54ad4cad4d4 --- /dev/null +++ b/app/javascript/controllers/index.js @@ -0,0 +1,11 @@ +// Import and register all your controllers from the importmap under controllers/* + +import { application } from "controllers/application" + +// Eager load all controllers defined in the import map under controllers/**/*_controller +import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" +eagerLoadControllersFrom("controllers", application) + +// Lazy load controllers as they appear in the DOM (remember not to preload controllers in import map!) +// import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading" +// lazyLoadControllersFrom("controllers", application) diff --git a/app/javascript/controllers/nav_controller.js b/app/javascript/controllers/nav_controller.js new file mode 100644 index 00000000000..ecf639c9ee6 --- /dev/null +++ b/app/javascript/controllers/nav_controller.js @@ -0,0 +1,39 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = [ + "collapse", // targets that receive expanded class when mobile nav shows + "header", // target on which clicks don't hide mobile nav + "logo", + "search", + ] + static classes = ["expanded"] + + connect() { this.skipSandwichIcon = true } + + toggle(e) { + e.preventDefault(); + if (this.collapseTarget.classList.contains(this.expandedClass)) { + this.leave() + this.logoTarget.focus(); + } else { + this.enter() + } + } + + focus() { + if (this.skipSandwichIcon) { // skip sandwich icon when you tab from "gem" icon + this.enter(); + this.hasSearchTarget && this.searchTarget.focus(); + this.skipSandwichIcon = false; + } else { + this.leave(); + this.logoTarget.focus(); + this.skipSandwichIcon = true; + } + } + + hide(e) { !this.headerTarget.contains(e.target) && this.leave() } + leave() { this.collapseTargets.forEach(el => el.classList.remove(this.expandedClass)) } + enter() { this.collapseTargets.forEach(el => el.classList.add(this.expandedClass)) } +} diff --git a/app/javascript/controllers/recovery_controller.js b/app/javascript/controllers/recovery_controller.js new file mode 100644 index 00000000000..832bd691bf1 --- /dev/null +++ b/app/javascript/controllers/recovery_controller.js @@ -0,0 +1,39 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static values = { + confirm: { type: String, default: "Leave without copying recovery codes?" } + } + + connect() { + this.copied = false; + window.addEventListener("beforeunload", this.popUp); + } + + popUp(e) { + e.preventDefault(); + e.returnValue = ""; + } + + copy() { + if (!this.copied) { + this.copied = true; + window.removeEventListener("beforeunload", this.popUp); + } + } + + submit(e) { + e.preventDefault(); + + if (!this.element.checkValidity()) { + this.element.reportValidity(); + return; + } + + if (this.copied || confirm(this.confirmValue)) { + window.removeEventListener("beforeunload", this.popUp); + // Don't include the form data in the URL. + window.location.href = this.element.action; + } + } +} diff --git a/app/javascript/controllers/search_controller.js b/app/javascript/controllers/search_controller.js new file mode 100644 index 00000000000..c8a9a2bbcff --- /dev/null +++ b/app/javascript/controllers/search_controller.js @@ -0,0 +1,15 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = [ "query", "attribute" ] + + input(e) { + this.queryTarget.value = this.attributeTargets.map(field => + field.value.length > 0 && field.checkValidity() ? `${field.name}: ${field.value}` : '' + ).join(' ') + } + + submit() { + this.queryTarget.form.submit() + } +} diff --git a/app/javascript/controllers/stats_controller.js b/app/javascript/controllers/stats_controller.js new file mode 100644 index 00000000000..0f17d3f2d66 --- /dev/null +++ b/app/javascript/controllers/stats_controller.js @@ -0,0 +1,10 @@ +import { Controller } from "@hotwired/stimulus" +import $ from 'jquery' + +export default class extends Controller { + static values = { width: String } + + connect() { + $(this.element).animate({ width: this.widthValue + '%' }, 700).removeClass('t-item--hidden').css("display", "block"); + } +} diff --git a/app/javascript/src/oidc_api_key_role_form.js b/app/javascript/src/oidc_api_key_role_form.js new file mode 100644 index 00000000000..55752d5fa37 --- /dev/null +++ b/app/javascript/src/oidc_api_key_role_form.js @@ -0,0 +1,34 @@ +import $ from "jquery"; + +$(function () { + function wire() { + var removeNestedButtons = $("button.form__remove_nested_button"); + + removeNestedButtons.off("click"); + removeNestedButtons.click(function (e) { + e.preventDefault(); + var button = $(this); + var nestedField = button.closest(".form__nested_fields"); + + nestedField.remove(); + }); + + var addNestedButtons = $("button.form__add_nested_button"); + addNestedButtons.off("click"); + addNestedButtons.click(function (e) { + e.preventDefault(); + var button = $(this); + var nestedFields = button.siblings("template.form__nested_fields"); + + var content = nestedFields + .html() + .replace(/NEW_OBJECT/g, new Date().getTime()); + + $(content).insertAfter(button.siblings().last()); + + wire(); + }); + } + + wire(); +}); diff --git a/app/javascript/src/pages.js b/app/javascript/src/pages.js new file mode 100644 index 00000000000..301e599cbba --- /dev/null +++ b/app/javascript/src/pages.js @@ -0,0 +1,11 @@ +import $ from "jquery"; + +//gem page +$(function() { + $('.gem__users__mfa-text.mfa-warn').on('click', function() { + $('.gem__users__mfa-text.mfa-warn').toggleClass('t-item--hidden'); + + $owners = $('.gem__users__mfa-disabled'); + $owners.toggleClass('t-item--hidden'); + }); +}); diff --git a/app/assets/javascripts/transitive_dependencies.js b/app/javascript/src/transitive_dependencies.js similarity index 85% rename from app/assets/javascripts/transitive_dependencies.js rename to app/javascript/src/transitive_dependencies.js index 36cccd41a68..619ca040b71 100755 --- a/app/assets/javascripts/transitive_dependencies.js +++ b/app/javascript/src/transitive_dependencies.js @@ -1,10 +1,14 @@ +import $ from "jquery"; + $(document).on('click', '.deps_expanded-link', function () { + try { var current = $(this); - var gem_id = $(this).attr('data-gem_id'); - var version_id = $(this).attr('data-version'); + var gem_id = this.dataset.gemId; + var version_id = this.dataset.version; + const url = "/gems/"+gem_id+"/versions/"+version_id+"/dependencies.json"; $.ajax({ type: "get", - url: "/gems/"+gem_id+"/versions/"+version_id+"/dependencies.json", + url: url, success: function(resp) { renderDependencies(resp, current); }, @@ -13,6 +17,9 @@ $(document).on('click', '.deps_expanded-link', function () { current.parent().next().next().html(error_message); } }); +} catch (e) { + alert(e); +} }); function renderDependencies(resp, current) { diff --git a/app/javascript/src/webauthn.js b/app/javascript/src/webauthn.js new file mode 100644 index 00000000000..406e11dd887 --- /dev/null +++ b/app/javascript/src/webauthn.js @@ -0,0 +1,180 @@ +import $ from "jquery"; +import { bufferToBase64url, base64urlToBuffer } from "webauthn-json" + +(function() { + const handleEvent = function(event) { + event.preventDefault(); + return event.target; + }; + + const setError = function(submit, error, message) { + submit.attr("disabled", false); + error.attr("hidden", false); + error.text(message); + }; + + const handleHtmlResponse = function(submit, responseError, response) { + if (response.redirected) { + window.location.href = response.url; + } else { + response.text().then(function (html) { + document.body.innerHTML = html; + }).catch(function (error) { + setError(submit, responseError, error); + }); + } + }; + + const credentialsToBase64 = function(credentials) { + return { + type: credentials.type, + id: credentials.id, + rawId: bufferToBase64url(credentials.rawId), + clientExtensionResults: credentials.clientExtensionResults, + response: { + authenticatorData: bufferToBase64url(credentials.response.authenticatorData), + attestationObject: bufferToBase64url(credentials.response.attestationObject), + clientDataJSON: bufferToBase64url(credentials.response.clientDataJSON), + signature: bufferToBase64url(credentials.response.signature), + userHandle: bufferToBase64url(credentials.response.userHandle), + }, + }; + }; + + const credentialsToBuffer = function(credentials) { + return credentials.map(function(credential) { + return { + id: base64urlToBuffer(credential.id), + type: credential.type + }; + }); + }; + + const parseCreationOptionsFromJSON = function(json) { + return { + ...json, + challenge: base64urlToBuffer(json.challenge), + user: { ...json.user, id: base64urlToBuffer(json.user.id) }, + excludeCredentials: credentialsToBuffer(json.excludeCredentials), + }; + }; + + const parseRequestOptionsFromJSON = function(json) { + return { + ...json, + challenge: base64urlToBuffer(json.challenge), + allowCredentials: credentialsToBuffer(json.allowCredentials), + }; + }; + + $(function() { + const credentialForm = $(".js-new-webauthn-credential--form"); + const credentialError = $(".js-new-webauthn-credential--error"); + const credentialSubmit = $(".js-new-webauthn-credential--submit"); + const csrfToken = $("[name='csrf-token']").attr("content"); + + credentialForm.submit(function(event) { + const form = handleEvent(event); + const nickname = $(".js-new-webauthn-credential--nickname").val(); + + fetch(form.action + ".json", { + method: "POST", + credentials: "same-origin", + headers: { "X-CSRF-Token": csrfToken } + }).then(function (response) { + return response.json(); + }).then(function (json) { + return navigator.credentials.create({ + publicKey: parseCreationOptionsFromJSON(json) + }); + }).then(function (credentials) { + return fetch(form.action + "/callback.json", { + method: "POST", + credentials: "same-origin", + headers: { + "X-CSRF-Token": csrfToken, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + credentials: credentialsToBase64(credentials), + webauthn_credential: { nickname: nickname } + }) + }); + }).then(function (response) { + response.json().then(function (json) { + if (json.redirect_url) { + window.location.href = json.redirect_url; + } else { + setError(credentialSubmit, credentialError, json.message); + } + }).catch(function (error) { + setError(credentialSubmit, credentialError, error); + }); + }).catch(function (error) { + setError(credentialSubmit, credentialError, error); + }); + }); + }); + + const getCredentials = async function(event, csrfToken) { + const form = handleEvent(event); + const options = JSON.parse(form.dataset.options); + const credentials = await navigator.credentials.get({ + publicKey: parseRequestOptionsFromJSON(options) + }); + return await fetch(form.action, { + method: "POST", + credentials: "same-origin", + redirect: "follow", + headers: { + "X-CSRF-Token": csrfToken, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + credentials: credentialsToBase64(credentials), + }) + }); + }; + + $(function() { + const cliSessionForm = $(".js-webauthn-session-cli--form"); + const cliSessionError = $(".js-webauthn-session-cli--error"); + const csrfToken = $("[name='csrf-token']").attr("content"); + + function failed_verification_url(message) { + const url = new URL(`${location.origin}/webauthn_verification/failed_verification`); + url.searchParams.append("error", message); + return url.href; + }; + + cliSessionForm.submit(function(event) { + getCredentials(event, csrfToken).then(function (response) { + response.text().then(function (text) { + if (text == "success") { + window.location.href = `${location.origin}/webauthn_verification/successful_verification`; + } else { + window.location.href = failed_verification_url(text); + } + }); + }).catch(function (error) { + window.location.href = failed_verification_url(error.message); + }); + }); + }); + + $(function() { + const sessionForm = $(".js-webauthn-session--form"); + const sessionSubmit = $(".js-webauthn-session--submit"); + const sessionError = $(".js-webauthn-session--error"); + const csrfToken = $("[name='csrf-token']").attr("content"); + + sessionForm.submit(async function(event) { + try { + const response = await getCredentials(event, csrfToken); + handleHtmlResponse(sessionSubmit, sessionError, response); + } catch (error) { + setError(sessionSubmit, sessionError, error); + } + }); + }); +})(); diff --git a/app/jobs/after_version_write_job.rb b/app/jobs/after_version_write_job.rb new file mode 100644 index 00000000000..651af4f5bdf --- /dev/null +++ b/app/jobs/after_version_write_job.rb @@ -0,0 +1,33 @@ +class AfterVersionWriteJob < ApplicationJob + queue_as :default + + def perform(version:) + version.transaction do + rubygem = version.rubygem + version.rubygem.push_notifiable_owners.each do |notified_user| + Mailer.gem_pushed(owner, version.id, notified_user.id).deliver_later + end + Indexer.perform_later + UploadVersionsFileJob.perform_later + UploadInfoFileJob.perform_later(rubygem_name: rubygem.name) + UploadNamesFileJob.perform_later + ReindexRubygemJob.perform_later(rubygem:) + StoreVersionContentsJob.perform_later(version:) if ld_variation(key: "gemcutter.pusher.store_version_contents", default: false) + version.update!(indexed: true) + checksum = GemInfo.new(rubygem.name, cached: false).info_checksum + version.update_attribute :info_checksum, checksum + end + end + + def ld_variation(key:, default:) + return default unless owner + + Rails.configuration.launch_darkly_client.variation( + key, owner.ld_context, default + ) + end + + def owner + arguments.dig(0, :version).pusher_api_key&.owner + end +end diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb new file mode 100644 index 00000000000..49707c8946a --- /dev/null +++ b/app/jobs/application_job.rb @@ -0,0 +1,49 @@ +class ApplicationJob < ActiveJob::Base + include SemanticLogger::Loggable + + PRIORITIES = ActiveSupport::OrderedOptions[{ + push: 1, + download: 2, + web_hook: 3, + profile_deletion: 3, + stats: 4 + }].freeze + + # Default to retrying errors a few times, so we don't get an alert for + # spurious errors + retry_on StandardError, wait: :polynomially_longer, attempts: 5 + + # 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 + + concerning "FeatureFlagging" do + included do + def ld_context + LaunchDarkly::LDContext.with_key(self.class.name, "active_job") + end + + def ld_variation(key:, default:) + Rails.configuration.launch_darkly_client.variation( + key, ld_context, default + ) + end + end + + class_methods do + def good_job_concurrency_perform_limit(default: nil) + proc do + ld_variation(key: "good_job.concurrency.perform_limit", default:) + end + end + + def good_job_concurrency_enqueue_limit(default: nil) + proc do + ld_variation(key: "good_job.concurrency.enqueue_limit", default:) + end + end + end + end +end diff --git a/app/jobs/delete_user.rb b/app/jobs/delete_user.rb deleted file mode 100644 index b86ee79f489..00000000000 --- a/app/jobs/delete_user.rb +++ /dev/null @@ -1,20 +0,0 @@ -class DeleteUser - attr_reader :user - - def initialize(user) - @user = user - end - - def perform - profile = User.find_by(id: user["id"]) - - return unless profile - - email = profile.email - if profile.destroy - Mailer.deletion_complete(email) - else - Mailer.deletion_failed(email) - end - end -end diff --git a/app/jobs/delete_user_job.rb b/app/jobs/delete_user_job.rb new file mode 100644 index 00000000000..9fc6fe50f3d --- /dev/null +++ b/app/jobs/delete_user_job.rb @@ -0,0 +1,15 @@ +class DeleteUserJob < ApplicationJob + queue_as :default + queue_with_priority PRIORITIES.fetch(:profile_deletion) + + def perform(user:) + email = user.email + return if user.discarded? + user.discard! + rescue ActiveRecord::ActiveRecordError, Discard::RecordNotDiscarded => e + # Catch the exception so we can log it, otherwise using `destroy` would give + # us no hint as to why the deletion failed. + Rails.error.report(e, context: { user: user.as_json, email: }, handled: true) + Mailer.deletion_failed(email).deliver_later + end +end diff --git a/app/jobs/email_confirmation_mailer.rb b/app/jobs/email_confirmation_mailer.rb deleted file mode 100644 index 451daf5cf13..00000000000 --- a/app/jobs/email_confirmation_mailer.rb +++ /dev/null @@ -1,11 +0,0 @@ -EmailConfirmationMailer = Struct.new(:user_id) do - def perform - user = User.find(user_id) - - if user.confirmation_token - Mailer.email_confirmation(user).deliver - else - Rails.logger.info("[jobs:email_confirmation_mailer] confirmation token not found. skipping sending mail for #{user.handle}") - end - end -end diff --git a/app/jobs/email_reset_mailer.rb b/app/jobs/email_reset_mailer.rb deleted file mode 100644 index 6b615d1a828..00000000000 --- a/app/jobs/email_reset_mailer.rb +++ /dev/null @@ -1,12 +0,0 @@ -EmailResetMailer = Struct.new(:user_id) do - def perform - user = User.find(user_id) - - if user.confirmation_token - Mailer.email_reset_update(user).deliver - Mailer.email_reset(user).deliver - else - Rails.logger.info("[jobs:email_reset_mailer] confirmation token not found. skipping sending mail for #{user.handle}") - end - end -end diff --git a/app/jobs/fastly_log_processor.rb b/app/jobs/fastly_log_processor.rb index 668407560a0..1fb362fdc58 100644 --- a/app/jobs/fastly_log_processor.rb +++ b/app/jobs/fastly_log_processor.rb @@ -23,21 +23,24 @@ def perform counts = download_counts(log_ticket) StatsD.gauge("fastly_log_processor.processed_versions_count", counts.count) - Delayed::Worker.logger.info "Processed Fastly log counts: #{counts.inspect}" + Rails.logger.info "Processed Fastly log counts: #{counts.inspect}" processed_count = counts.sum { |_, v| v } - ActiveRecord::Base.connection.transaction do + GemDownload.for_all_gems.with_lock do GemDownload.bulk_update(counts) log_ticket.update(status: "processed", processed_count: processed_count) end StatsD.gauge("fastly_log_processor.processed_count", processed_count) - rescue + rescue StandardError log_ticket&.update(status: "failed") raise end statsd_count_success :perform, "fastly_log_processor.perform" statsd_measure :perform, "fastly_log_processor.job_performance" + PATH_PATTERN = %r{/gems/(?.+)\.gem} + private_constant :PATH_PATTERN + # Takes an enumerator of log lines and returns a hash of download counts # E.g. # { @@ -47,17 +50,20 @@ def perform def download_counts(log_ticket) file = log_ticket.body raise LogFileNotFoundError if file.nil? - enumerator = file.each_line - enumerator.each_with_object(Hash.new(0)) do |log_line, accum| + ok_status = Rack::Utils::SYMBOL_TO_STATUS_CODE[:ok] + not_modified_status = Rack::Utils::SYMBOL_TO_STATUS_CODE[:not_modified] + + file.each_line.with_object(Hash.new(0)) do |log_line, accum| path, response_code = log_line.split[10, 2] + case response_code.to_i # Only count successful downloads # NB: we consider a 304 response a download attempt - if [200, 304].include?(response_code.to_i) && (match = path.match %r{/gems/(?.+)\.gem}) - accum[match[:path]] += 1 + when ok_status, not_modified_status + if (match = PATH_PATTERN.match(path)) + accum[match[:path]] += 1 + end end - - accum end end end diff --git a/app/jobs/fastly_log_processor_job.rb b/app/jobs/fastly_log_processor_job.rb new file mode 100644 index 00000000000..a3fe34df868 --- /dev/null +++ b/app/jobs/fastly_log_processor_job.rb @@ -0,0 +1,19 @@ +class FastlyLogProcessorJob < ApplicationJob + queue_as :default + queue_with_priority PRIORITIES.fetch(:stats) + + include GoodJob::ActiveJobExtensions::Concurrency + good_job_control_concurrency_with( + # Maximum number of jobs with the concurrency key to be + # concurrently performed (excludes enqueued jobs) + # + # Limited to avoid overloading the gem_download table with + # too many concurrent conflicting updates + perform_limit: good_job_concurrency_perform_limit(default: 5), + key: name + ) + + def perform(bucket:, key:) + FastlyLogProcessor.new(bucket, key).perform + end +end diff --git a/app/jobs/fastly_purge_job.rb b/app/jobs/fastly_purge_job.rb new file mode 100644 index 00000000000..fa5d3182ae5 --- /dev/null +++ b/app/jobs/fastly_purge_job.rb @@ -0,0 +1,20 @@ +class FastlyPurgeJob < ApplicationJob + queue_as :default + + class PassPathXorKeyError < ArgumentError + end + + discard_on PassPathXorKeyError + + before_perform do + raise PassPathXorKeyError, arguments.first.inspect if arguments.first.slice(:path, :key).compact.size != 1 + end + + def perform(soft:, path: nil, key: nil) + if path + Fastly.purge({ path:, soft: }) + elsif key + Fastly.purge_key(key, soft:) + end + end +end diff --git a/app/jobs/good_job_statsd_job.rb b/app/jobs/good_job_statsd_job.rb new file mode 100644 index 00000000000..704d6a80841 --- /dev/null +++ b/app/jobs/good_job_statsd_job.rb @@ -0,0 +1,67 @@ +class GoodJobStatsDJob < ApplicationJob + queue_as "stats" + + class Filter < GoodJob::JobsFilter + def filtered_query(filter_params = params) + super.then do |rel| + rel.group(:queue_name, :priority, Arel.sql("#{rel.quoted_table_name}.serialized_params->>'job_class'")) + end + end + end + + def perform + filter = Filter.new({}) + now = Time.now.utc + + state_counts = filter.states + gauge "count", state_counts + + if ld_variation(key: "good_job.GoodJobStatsDJob.measure_staleness", default: true) + state_staleness = state_counts.each_key.index_with do |state| + case state + when "scheduled", "queued" # not started jobs don't have discrete execution entry yet + columns = %w[executions_good_jobs.scheduled_at executions_good_jobs.created_at] + relation = :executions + when "retried", "running", "succeeded", "discarded" + columns = %w[good_job_executions.scheduled_at good_job_executions.created_at] + relation = :discrete_executions + else raise "unknown GoodJob state '#{state}'" + end + + staleness(now, filter.filtered_query(state:), columns, relation) + end + gauge "staleness", state_staleness + end + + if ld_variation(key: "good_job.GoodJobStatsDJob.measure_latest_execution", default: true) + state_latest_execution = state_counts.each_key.index_with do |state| + columns = %w[good_job_executions.performed_at good_job_executions.finished_at good_job_executions.scheduled_at good_job_executions.created_at] + staleness(now, filter.filtered_query(state:), columns, :discrete_executions) + end + gauge "latest_execution", state_latest_execution + end + + nil + end + + def staleness(now, filtered_query, columns, joined_relation) + filtered_query.joins(joined_relation).then do |rel| + rel.pluck( + *rel.group_values, + Arel::Nodes::Max.new( + [Arel::Nodes.build_quoted(now, rel.arel_table[:created_at]) - + rel.arel_table.coalesce(*rel.send(:arel_columns, columns))] + ) + ) + .to_h { |*a, v| [a, v] } + end + end + + def gauge(key, values) + values.each do |state, tags| + tags.each do |(queue, priority, job_class), value| + StatsD.gauge("good_job.#{key}", value, tags: ["state:#{state}", "queue:#{queue}", "priority:#{priority}", "job_class:#{job_class}"]) + end + end + end +end diff --git a/app/jobs/hook_relay_report_job.rb b/app/jobs/hook_relay_report_job.rb new file mode 100644 index 00000000000..b679c43af3d --- /dev/null +++ b/app/jobs/hook_relay_report_job.rb @@ -0,0 +1,34 @@ +class HookRelayReportJob < ApplicationJob + queue_as :default + self.queue_adapter = :good_job + queue_with_priority PRIORITIES.fetch(:stats) + + class UnknownStatusError < StandardError + end + + class MalformedStreamError < StandardError + end + + discard_on UnknownStatusError + discard_on MalformedStreamError + + before_perform do + stream = arguments[0].fetch(:stream) + id = stream.slice(/:webhook_id-(\d+)\z/, 1) + raise MalformedStreamError, stream.inspect unless id + @hook = WebHook.find(id.to_i) + end + + def perform(params) + completed_at = params.fetch(:completed_at).to_datetime + + case params.fetch(:status) + when "failure" + @hook.failure!(completed_at:) + when "success" + @hook.success!(completed_at:) + else + raise UnknownStatusError, params.fetch(:status).inspect + end + end +end diff --git a/app/jobs/indexer.rb b/app/jobs/indexer.rb index b48ee40d271..df939032cce 100644 --- a/app/jobs/indexer.rb +++ b/app/jobs/indexer.rb @@ -1,5 +1,20 @@ -class Indexer +class Indexer < ApplicationJob extend StatsD::Instrument + include TraceTagger + + queue_with_priority PRIORITIES.fetch(:push) + + include GoodJob::ActiveJobExtensions::Concurrency + good_job_control_concurrency_with( + # Maximum number of jobs with the concurrency key to be + # concurrently enqueued (excludes performing jobs) + # + # Because the indexer job only uses current state at time of perform, + # it makes no sense to enqueue more than one at a time + enqueue_limit: good_job_concurrency_enqueue_limit(default: 1), + perform_limit: good_job_concurrency_perform_limit(default: 1), + key: name + ) def perform log "Updating the index" @@ -10,47 +25,38 @@ def perform statsd_count_success :perform, "Indexer.perform" statsd_measure :perform, "Indexer.perform" - def write_gem(body, spec) - RubygemFs.instance.store("gems/#{spec.original_name}.gem", body.string) - - spec.abbreviate - spec.sanitize - - RubygemFs.instance.store( - "quick/Marshal.4.8/#{spec.original_name}.gemspec.rz", - Gem.deflate(Marshal.dump(spec)) - ) - end - private def stringify(value) - final = StringIO.new + final = ActiveSupport::Gzip::Stream.new gzip = Zlib::GzipWriter.new(final) - gzip.write(Marshal.dump(value)) + Marshal.dump(value, gzip) gzip.close final.string end def upload(key, value) - RubygemFs.instance.store(key, stringify(value), "surrogate-key" => "full-index") + RubygemFs.instance.store(key, stringify(value), metadata: { "surrogate-key" => "full-index" }) end def update_index - upload("specs.4.8.gz", specs_index) - log "Uploaded all specs index" - upload("latest_specs.4.8.gz", latest_index) - log "Uploaded latest specs index" - upload("prerelease_specs.4.8.gz", prerelease_index) - log "Uploaded prerelease specs index" + trace("gemcutter.indexer.index", resource: "specs.4.8.gz") do + upload("specs.4.8.gz", specs_index) + log "Uploaded all specs index" + end + trace("gemcutter.indexer.index", resource: "latest_specs.4.8.gz") do + upload("latest_specs.4.8.gz", latest_index) + log "Uploaded latest specs index" + end + trace("gemcutter.indexer.index", resource: "prerelease_specs.4.8.gz") do + upload("prerelease_specs.4.8.gz", prerelease_index) + log "Uploaded prerelease specs index" + end end def purge_cdn - return unless ENV["FASTLY_SERVICE_ID"] && ENV["FASTLY_API_KEY"] - - Fastly.purge_key("full-index") - log "Purged index urls from fastly" + log "Purged index urls from fastly" if Fastly.purge_key("full-index") end def minimize_specs(data) @@ -80,6 +86,6 @@ def prerelease_index end def log(message) - Rails.logger.info "[GEMCUTTER:#{Time.zone.now}] #{message}" + logger.info message end end diff --git a/app/jobs/mfa_usage_stats_job.rb b/app/jobs/mfa_usage_stats_job.rb new file mode 100644 index 00000000000..e39d597d78b --- /dev/null +++ b/app/jobs/mfa_usage_stats_job.rb @@ -0,0 +1,15 @@ +class MfaUsageStatsJob < ApplicationJob + queue_as "stats" + + def perform + non_mfa_users = User.where(totp_seed: nil).where.not(id: WebauthnCredential.select(:user_id)).count + totp_only_users = User.where.not(totp_seed: nil).where.not(id: WebauthnCredential.select(:user_id)).count + webauthn_only_users = User.where(totp_seed: nil).where(id: WebauthnCredential.select(:user_id)).count + webauthn_and_totp_users = User.where.not(totp_seed: nil).where(id: WebauthnCredential.select(:user_id)).count + + StatsD.gauge("mfa_usage_stats.non_mfa_users", non_mfa_users) + StatsD.gauge("mfa_usage_stats.totp_only_users", totp_only_users) + StatsD.gauge("mfa_usage_stats.webauthn_only_users", webauthn_only_users) + StatsD.gauge("mfa_usage_stats.webauthn_and_totp_users", webauthn_and_totp_users) + end +end diff --git a/app/jobs/notifier.rb b/app/jobs/notifier.rb deleted file mode 100644 index 2bd013c41ba..00000000000 --- a/app/jobs/notifier.rb +++ /dev/null @@ -1,35 +0,0 @@ -require "timeout" - -Notifier = Struct.new(:url, :protocol, :host_with_port, :rubygem, :version, :api_key) do - extend StatsD::Instrument - - def payload - rubygem.payload(version, protocol, host_with_port).to_json - end - - def authorization - Digest::SHA2.hexdigest(rubygem.name + version.number + api_key) - end - - def perform - timeout(5) do - RestClient.post url, - payload, - :timeout => 5, - :open_timeout => 5, - "Content-Type" => "application/json", - "Authorization" => authorization - end - true - rescue *(HTTP_ERRORS + [RestClient::Exception, SocketError, SystemCallError]) => _e - WebHook.find_by_url(url).try(:increment!, :failure_count) - false - end - statsd_count_success :perform, "Webhook.perform" - - private - - def timeout(sec, &block) - Timeout.timeout(sec, &block) - end -end diff --git a/app/jobs/notify_web_hook_job.rb b/app/jobs/notify_web_hook_job.rb new file mode 100644 index 00000000000..1f1de9abac0 --- /dev/null +++ b/app/jobs/notify_web_hook_job.rb @@ -0,0 +1,104 @@ +class NotifyWebHookJob < ApplicationJob + extend StatsD::Instrument + include TraceTagger + + queue_as :default + queue_with_priority PRIORITIES.fetch(:web_hook) + + TIMEOUT_SEC = 5 + + before_perform { @kwargs = arguments.last.then { _1 if Hash.ruby2_keywords_hash?(_1) } } + before_perform { @webhook = @kwargs.fetch(:webhook) } + before_perform { @protocol = @kwargs.fetch(:protocol) } + before_perform { @host_with_port = @kwargs.fetch(:host_with_port) } + before_perform { @version = @kwargs.fetch(:version) } + before_perform { @rubygem = @version.rubygem } + before_perform { @poll_delivery = @kwargs.fetch(:poll_delivery, false) } + before_perform do + @http = Faraday.new("https://api.hookrelay.dev", request: { timeout: TIMEOUT_SEC }) do |f| + f.request :json + f.response :logger, logger, headers: false, errors: true + f.response :json + f.response :raise_error + end + end + + attr_reader :webhook, :protocol, :host_with_port, :version, :rubygem + + ERRORS = (HTTP_ERRORS + [Faraday::Error, SocketError, SystemCallError, OpenSSL::SSL::SSLError]).freeze + retry_on(*ERRORS) + + # has to come after the retry on + discard_on(Faraday::UnprocessableEntityError) do |j, e| + logger.info({ webhook_id: j.webhook.id, url: j.webhook.url, response: JSON.parse(e.response_body) }) + j.webhook.increment! :failure_count + end + + def perform(*) + url = webhook.url + set_tag "gemcutter.notifier.url", url + set_tag "gemcutter.notifier.webhook_id", webhook.id + + post_hook_relay + end + statsd_count_success :perform, "Webhook.perform" + + def payload + rubygem.payload(version, protocol, host_with_port).to_json + end + + def authorization + Digest::SHA2.hexdigest([rubygem.name, version.number, webhook.api_key].compact.join) + end + + def hook_relay_url + "https://api.hookrelay.dev/hooks/#{account_id}/#{hook_id}/webhook_id-#{webhook.id || 'fire'}" + end + + def post_hook_relay + response = post(hook_relay_url) + delivery_id = response.body.fetch("id") + Rails.logger.info do + { webhook_id: webhook.id, url: webhook.url, delivery_id:, full_name: version.full_name, message: "Sent webhook to HookRelay" } + end + return poll_delivery(delivery_id) if @poll_delivery + true + end + + def post(url) + @http.post( + url, payload, + { + "Authorization" => authorization, + "HR_TARGET_URL" => webhook.url, + "HR_MAX_ATTEMPTS" => @poll_delivery ? "1" : "3" + } + ) + end + + def poll_delivery(delivery_id) + deadline = (Rails.env.test? ? 0.01 : 10).seconds.from_now + response = nil + until Time.zone.now > deadline + sleep 0.5 + response = @http.get("https://app.hookrelay.dev/api/v1/accounts/#{account_id}/hooks/#{hook_id}/deliveries/#{delivery_id}", nil, { + "Authorization" => "Bearer #{ENV['HOOK_RELAY_API_KEY']}" + }) + status = response.body.fetch("status") + + break if status == "success" + end + + response.body || raise("Failed to get delivery status after 10 seconds") + end + + private + + def account_id + ENV["HOOK_RELAY_ACCOUNT_ID"] + end + + def hook_id + ENV["HOOK_RELAY_HOOK_ID"] + end +end diff --git a/app/jobs/process_sendgrid_event_job.rb b/app/jobs/process_sendgrid_event_job.rb new file mode 100644 index 00000000000..03ebf07bf84 --- /dev/null +++ b/app/jobs/process_sendgrid_event_job.rb @@ -0,0 +1,7 @@ +class ProcessSendgridEventJob < ApplicationJob + queue_as :default + + def perform(sendgrid_event:) + sendgrid_event.process + end +end diff --git a/app/jobs/refresh_oidc_provider_job.rb b/app/jobs/refresh_oidc_provider_job.rb new file mode 100644 index 00000000000..e7edcb433cd --- /dev/null +++ b/app/jobs/refresh_oidc_provider_job.rb @@ -0,0 +1,27 @@ +class RefreshOIDCProviderJob < ApplicationJob + queue_as :default + + ERRORS = (HTTP_ERRORS + [Faraday::Error, SocketError, SystemCallError, OpenSSL::SSL::SSLError]).freeze + retry_on(*ERRORS) + + class JWKSURIMismatchError < StandardError; end + + def perform(provider:) + connection = Faraday.new(provider.issuer, request: { timeout: 2 }, headers: { "Accept" => "application/json" }) do |f| + f.request :json + f.response :logger, logger, headers: false, errors: true, bodies: true + f.response :raise_error + f.response :json, content_type: // + end + resp = connection.get("/.well-known/openid-configuration") + + provider.configuration = resp.body + provider.configuration.validate! + if provider.configuration.jwks_uri.blank? || URI.parse(provider.configuration.jwks_uri).host != URI.parse(provider.issuer).host + raise JWKSURIMismatchError, "Invalid JWKS URI in OpenID Connect configuration #{provider.configuration.jwks_uri.inspect}" + end + provider.jwks = connection.get(provider.configuration.jwks_uri).body + + provider.save! + end +end diff --git a/app/jobs/refresh_oidc_providers_job.rb b/app/jobs/refresh_oidc_providers_job.rb new file mode 100644 index 00000000000..c91b336e74e --- /dev/null +++ b/app/jobs/refresh_oidc_providers_job.rb @@ -0,0 +1,9 @@ +class RefreshOIDCProvidersJob < ApplicationJob + queue_as :default + + def perform(*_args) + OIDC::Provider.find_each do |provider| + RefreshOIDCProviderJob.perform_later(provider:) + end + end +end diff --git a/app/jobs/reindex_rubygem_job.rb b/app/jobs/reindex_rubygem_job.rb new file mode 100644 index 00000000000..ee31448b519 --- /dev/null +++ b/app/jobs/reindex_rubygem_job.rb @@ -0,0 +1,7 @@ +class ReindexRubygemJob < ApplicationJob + queue_as :default + + def perform(rubygem:) + rubygem.reindex + end +end diff --git a/app/jobs/rstuf/add_job.rb b/app/jobs/rstuf/add_job.rb new file mode 100644 index 00000000000..fc76f65e4a5 --- /dev/null +++ b/app/jobs/rstuf/add_job.rb @@ -0,0 +1,18 @@ +class Rstuf::AddJob < Rstuf::ApplicationJob + queue_with_priority PRIORITIES.fetch(:push) + + def perform(version:) + target = { + info: { + length: version.size, + hashes: { + sha256: version.sha256_hex + } + }, + path: version.gem_file_name + } + + task_id = Rstuf::Client.post_artifacts([target]) + Rstuf::CheckJob.set(wait: Rstuf.wait_for).perform_later(task_id) + end +end diff --git a/app/jobs/rstuf/application_job.rb b/app/jobs/rstuf/application_job.rb new file mode 100644 index 00000000000..21b6f01d992 --- /dev/null +++ b/app/jobs/rstuf/application_job.rb @@ -0,0 +1,5 @@ +class Rstuf::ApplicationJob < ApplicationJob + before_enqueue do + throw :abort unless Rstuf.enabled? + end +end diff --git a/app/jobs/rstuf/check_job.rb b/app/jobs/rstuf/check_job.rb new file mode 100644 index 00000000000..c66da399fe2 --- /dev/null +++ b/app/jobs/rstuf/check_job.rb @@ -0,0 +1,24 @@ +class Rstuf::CheckJob < Rstuf::ApplicationJob + RetryException = Class.new(StandardError) + FailureException = Class.new(StandardError) + ErrorException = Class.new(StandardError) + retry_on RetryException, wait: :polynomially_longer, attempts: 10 + + queue_with_priority PRIORITIES.fetch(:push) + + def perform(task_id) + case status = Rstuf::Client.task_state(task_id) + when "SUCCESS" + # no-op, all good + when "FAILURE" + raise FailureException, "RSTUF job failed, please check payload and retry" + when "ERRORED", "REVOKED", "REJECTED" + raise ErrorException, "RSTUF internal problem, please check RSTUF health" + when "PENDING", "RUNNING", "RECEIVED", "STARTED" + raise RetryException + else + Rails.logger.info "RSTUF job returned unexpected state #{status}" + raise RetryException + end + end +end diff --git a/app/jobs/rstuf/remove_job.rb b/app/jobs/rstuf/remove_job.rb new file mode 100644 index 00000000000..14a0b600cae --- /dev/null +++ b/app/jobs/rstuf/remove_job.rb @@ -0,0 +1,8 @@ +class Rstuf::RemoveJob < Rstuf::ApplicationJob + queue_with_priority PRIORITIES.fetch(:push) + + def perform(version:) + task_id = Rstuf::Client.delete_artifacts([version.gem_file_name]) + Rstuf::CheckJob.set(wait: Rstuf.wait_for).perform_later(task_id) + end +end diff --git a/app/jobs/store_version_contents_job.rb b/app/jobs/store_version_contents_job.rb new file mode 100644 index 00000000000..43988fb6a9a --- /dev/null +++ b/app/jobs/store_version_contents_job.rb @@ -0,0 +1,38 @@ +class StoreVersionContentsJob < ApplicationJob + include GoodJob::ActiveJobExtensions::Concurrency + good_job_control_concurrency_with total_limit: 1, key: -> { "store-contents-#{version_arg.full_name}" } + queue_as :version_contents + + class VersionNotIndexed < RuntimeError; end + class GemNotFound < RuntimeError; end + + discard_on ActiveJob::DeserializationError + discard_on Gem::Package::FormatError, Gem::Security::Exception + + retry_on VersionNotIndexed, wait: :polynomially_longer, attempts: 5 + retry_on GemNotFound, wait: :polynomially_longer, attempts: 5 + + rescue_from(GemNotFound, Gem::Package::FormatError, Gem::Security::Exception) do |error| + version = version_arg.full_name + logger.error "Storing gem contents for #{version} failed", error + Rails.error.report error, context: { version: version }, handled: false + raise error + end + + def version_arg + arguments.first[:version] + end + + def perform(version:) + raise VersionNotIndexed, "Version #{version&.full_name.inspect} is not indexed" unless version&.indexed? + logger.info "Storing gem contents for #{version.full_name}" + + gem = RubygemFs.instance.get("gems/#{version.gem_file_name}") + raise GemNotFound, "Gem file not found: #{version.gem_file_name}" unless gem + + package = Gem::Package.new(StringIO.new(gem)) + version.manifest.store_package package + + logger.info "Storing gem contents for #{version.full_name} succeeded" + end +end diff --git a/app/jobs/upload_info_file_job.rb b/app/jobs/upload_info_file_job.rb new file mode 100644 index 00000000000..61d33e4652a --- /dev/null +++ b/app/jobs/upload_info_file_job.rb @@ -0,0 +1,48 @@ +class UploadInfoFileJob < ApplicationJob + queue_with_priority PRIORITIES.fetch(:push) + + include GoodJob::ActiveJobExtensions::Concurrency + good_job_control_concurrency_with( + # Maximum number of jobs with the concurrency key to be + # concurrently enqueued (excludes performing jobs) + # + # Because the job only uses current state at time of perform, + # it makes no sense to enqueue more than one at a time + enqueue_limit: good_job_concurrency_enqueue_limit(default: 1), + perform_limit: good_job_concurrency_perform_limit(default: 1), + key: -> { "#{self.class.name}:#{rubygem_name_arg}" } + ) + + def perform(rubygem_name:) + compact_index_info = GemInfo.new(rubygem_name, cached: false).compact_index_info + response_body = CompactIndex.info(compact_index_info) + + content_md5 = Digest::MD5.base64digest(response_body) + checksum_sha256 = Digest::SHA256.base64digest(response_body) + + response = RubygemFs.compact_index.store( + "info/#{rubygem_name}", response_body, + public_acl: false, # the compact-index bucket does not have ACLs enabled + metadata: { + "surrogate-control" => "max-age=3600, stale-while-revalidate=1800", + "surrogate-key" => "info/* info/#{rubygem_name} gem/#{rubygem_name} s3-compact-index s3-info/* s3-info/#{rubygem_name}", + "sha256" => checksum_sha256, + "md5" => content_md5 + }, + cache_control: "max-age=60, public", + content_type: "text/plain; charset=utf-8", + checksum_sha256:, + content_md5: + ) + + logger.info(message: "Uploading info file for #{rubygem_name} succeeded", response:) + + FastlyPurgeJob.perform_later(key: "s3-info/#{rubygem_name}", soft: true) + end + + private + + def rubygem_name_arg + arguments.first.fetch(:rubygem_name) + end +end diff --git a/app/jobs/upload_names_file_job.rb b/app/jobs/upload_names_file_job.rb new file mode 100644 index 00000000000..3e9cc1cd130 --- /dev/null +++ b/app/jobs/upload_names_file_job.rb @@ -0,0 +1,42 @@ +class UploadNamesFileJob < ApplicationJob + queue_with_priority PRIORITIES.fetch(:push) + + include GoodJob::ActiveJobExtensions::Concurrency + good_job_control_concurrency_with( + # Maximum number of jobs with the concurrency key to be + # concurrently enqueued (excludes performing jobs) + # + # Because the job only uses current state at time of perform, + # it makes no sense to enqueue more than one at a time + enqueue_limit: good_job_concurrency_enqueue_limit(default: 1), + perform_limit: good_job_concurrency_perform_limit(default: 1), + key: name + ) + + def perform + names = GemInfo.ordered_names(cached: false) + response_body = CompactIndex.names(names) + + content_md5 = Digest::MD5.base64digest(response_body) + checksum_sha256 = Digest::SHA256.base64digest(response_body) + + response = RubygemFs.compact_index.store( + "names", response_body, + public_acl: false, # the compact-index bucket does not have ACLs enabled + metadata: { + "surrogate-control" => "max-age=3600, stale-while-revalidate=1800", + "surrogate-key" => "names s3-compact-index s3-names", + "sha256" => checksum_sha256, + "md5" => content_md5 + }, + cache_control: "max-age=60, public", + content_type: "text/plain; charset=utf-8", + checksum_sha256:, + content_md5: + ) + + logger.info(message: "Uploading names file succeeded", response:) + + FastlyPurgeJob.perform_later(key: "s3-names", soft: true) + end +end diff --git a/app/jobs/upload_versions_file_job.rb b/app/jobs/upload_versions_file_job.rb new file mode 100644 index 00000000000..63f5f2510f7 --- /dev/null +++ b/app/jobs/upload_versions_file_job.rb @@ -0,0 +1,48 @@ +class UploadVersionsFileJob < ApplicationJob + queue_with_priority PRIORITIES.fetch(:push) + + include GoodJob::ActiveJobExtensions::Concurrency + good_job_control_concurrency_with( + # Maximum number of jobs with the concurrency key to be + # concurrently enqueued (excludes performing jobs) + # + # Because the job only uses current state at time of perform, + # it makes no sense to enqueue more than one at a time + enqueue_limit: good_job_concurrency_enqueue_limit(default: 1), + perform_limit: good_job_concurrency_perform_limit(default: 1), + key: name + ) + + def perform + versions_path = Rails.application.config.rubygems["versions_file_location"] + versions_file = CompactIndex::VersionsFile.new(versions_path) + from_date = versions_file.updated_at + + logger.info "Generating versions file from #{from_date}" + + extra_gems = GemInfo.compact_index_versions(from_date) + response_body = CompactIndex.versions(versions_file, extra_gems) + + content_md5 = Digest::MD5.base64digest(response_body) + checksum_sha256 = Digest::SHA256.base64digest(response_body) + + response = RubygemFs.compact_index.store( + "versions", response_body, + public_acl: false, # the compact-index bucket does not have ACLs enabled + metadata: { + "surrogate-control" => "max-age=3600, stale-while-revalidate=1800", + "surrogate-key" => "versions s3-compact-index s3-versions", + "sha256" => checksum_sha256, + "md5" => content_md5 + }, + cache_control: "max-age=60, public", + content_type: "text/plain; charset=utf-8", + checksum_sha256:, + content_md5: + ) + + logger.info(message: "Uploading versions file succeeded", response:) + + FastlyPurgeJob.perform_later(key: "s3-versions", soft: true) + end +end diff --git a/app/jobs/verify_link_job.rb b/app/jobs/verify_link_job.rb new file mode 100644 index 00000000000..8a18a838cf1 --- /dev/null +++ b/app/jobs/verify_link_job.rb @@ -0,0 +1,101 @@ +class VerifyLinkJob < ApplicationJob + queue_as :default + + retry_on ActiveRecord::RecordNotFound, ActiveRecord::RecordInvalid, wait: :polynomially_longer, attempts: 3 + + ERRORS = (HTTP_ERRORS + [Faraday::Error, SocketError, SystemCallError, OpenSSL::SSL::SSLError]).freeze + + class NotHTTPSError < StandardError; end + class LinkNotPresentError < StandardError; end + class HTTPResponseError < StandardError; end + + rescue_from LinkNotPresentError, HTTPResponseError, *ERRORS do |error| + logger.info "Linkback verification failed with error: #{error.message}", error: error, uri: link_verification.uri, + linkable: link_verification.linkable.to_gid + + link_verification.transaction do + record_failure + retry_job(wait: 5.seconds * (3.5**link_verification.failures_since_last_verification.pred), error:) if should_retry? + end + end + + rescue_from NotHTTPSError, Faraday::RestrictIPAddresses::AddressNotAllowed do |_error| + record_failure + end + + TIMEOUT_SEC = 5 + + def perform(link_verification:) + verify_link!(link_verification.uri, link_verification.linkable) + record_success + end + + def link_verification + arguments.first.fetch(:link_verification) + end + + def verify_link!(uri, linkable) + raise NotHTTPSError unless uri.start_with?("https://") + + expected_href = linkable.linkable_verification_uri.to_s.downcase + + response = get(uri) + raise HTTPResponseError, "Expected 200, got #{response.status}" unless response.status == 200 + # TODO: body_with_limit, https://github.com/mastodon/mastodon/blob/33c8708a1ac7df363bf2bd74ab8fa2ed7168379c/app/lib/request.rb#L246 + doc = Nokogiri::HTML5(response.body) + + xpaths = [ + # rel=me, what mastodon uses for profile link verification + '//a[contains(concat(" ", normalize-space(@rel), " "), " me ")]', + '//link[contains(concat(" ", normalize-space(@rel), " "), " me ")]', + + # rel=rubygem + '//a[contains(concat(" ", normalize-space(@rel), " "), " rubygem ")]', + '//link[contains(concat(" ", normalize-space(@rel), " "), " rubygem ")]' + ] + + if URI(uri).host == "github.com" + # github doesn't set a rel attribute on the URL added to a repo, so we have to use role=link instead + xpaths << '//a[contains(concat(" ", normalize-space(@role), " "), " link ")]' + xpaths << '//link[contains(concat(" ", normalize-space(@role), " "), " link ")]' + end + + links = doc.xpath(xpaths.join("|")) + + return if links.any? { |link| link["href"]&.downcase == expected_href } + raise LinkNotPresentError, "Expected #{expected_href} to be present in #{uri}" + end + + def get(url) + Faraday.new(nil, request: { timeout: TIMEOUT_SEC }) do |f| + # prevent SSRF attacks + f.request :restrict_ip_addresses, deny_rfc6890: true + + f.response :logger, logger, headers: false, errors: true + f.response :raise_error + end.get( + url, + {}, + { + "User-Agent" => "RubyGems.org Linkback Verification/#{AppRevision.version}", + "Accept" => "text/html" + } + ) + end + + def should_retry? + link_verification.failures_since_last_verification < LinkVerification::MAX_FAILURES + end + + def record_success + link_verification.update!( + last_verified_at: Time.current, + failures_since_last_verification: 0 + ) + end + + def record_failure + link_verification.touch(:last_failure_at) + link_verification.increment!(:failures_since_last_verification) + end +end diff --git a/app/jobs/yank_version_contents_job.rb b/app/jobs/yank_version_contents_job.rb new file mode 100644 index 00000000000..c7e5372dc8a --- /dev/null +++ b/app/jobs/yank_version_contents_job.rb @@ -0,0 +1,22 @@ +class YankVersionContentsJob < ApplicationJob + include GoodJob::ActiveJobExtensions::Concurrency + good_job_control_concurrency_with total_limit: 1, key: -> { "yank-contents-#{version_arg&.full_name}" } + queue_as :version_contents + + class VersionNotYanked < RuntimeError; end + + discard_on ActiveJob::DeserializationError + + retry_on VersionNotYanked, wait: :polynomially_longer, attempts: 5 + + def version_arg + arguments.first[:version] + end + + def perform(version:) + raise VersionNotYanked, "Version #{version&.full_name.inspect} is not yanked" unless version&.yanked? + logger.info "Yanking gem contents for #{version.full_name}" + version.manifest.yank + logger.info "Yanking gem contents for #{version.full_name} succeeded" + end +end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 47cdc622f63..d65a66a2a30 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,3 +1,27 @@ class ApplicationMailer < ActionMailer::Base + include SemanticLogger::Loggable + include Roadie::Rails::Automatic + include MailerHelper + + default from: Gemcutter::MAIL_SENDER + default_url_options[:host] = Gemcutter::HOST + default_url_options[:protocol] = Gemcutter::PROTOCOL + layout "mailer" + + after_deliver :record_delivery + + def record_delivery + message.to_addrs&.each do |address| + next unless (user = User.find_by_email(address)) + + user.record_event!(Events::UserEvent::EMAIL_SENT, + to: address, + from: message.from_addrs&.first, + subject: message.subject, + message_id: message.message_id, + action: action_name, + mailer: mailer_name) + end + end end diff --git a/app/mailers/clearance_mailer.rb b/app/mailers/clearance_mailer.rb deleted file mode 100644 index 15d480030bb..00000000000 --- a/app/mailers/clearance_mailer.rb +++ /dev/null @@ -1,18 +0,0 @@ -class ClearanceMailer < ApplicationMailer - include Roadie::Rails::Automatic - - default_url_options[:host] = Gemcutter::HOST - default_url_options[:protocol] = Gemcutter::PROTOCOL - - def change_password(user) - @user = User.find(user["id"]) - mail( - from: Clearance.configuration.mailer_sender, - to: @user.email, - subject: I18n.t( - :change_password, - scope: %i[clearance models clearance_mailer] - ) - ) - end -end diff --git a/app/mailers/mailer.rb b/app/mailers/mailer.rb index 591349f4e5f..98bda5678fe 100644 --- a/app/mailers/mailer.rb +++ b/app/mailers/mailer.rb @@ -1,29 +1,44 @@ class Mailer < ApplicationMailer - include Roadie::Rails::Automatic - - default from: Clearance.configuration.mailer_sender - - default_url_options[:host] = Gemcutter::HOST - default_url_options[:protocol] = Gemcutter::PROTOCOL - def email_reset(user) @user = user mail to: @user.unconfirmed_email, - subject: I18n.t("mailer.confirmation_subject", - default: "Please confirm your email address with RubyGems.org") + subject: I18n.t("mailer.confirmation_subject", host: Gemcutter::HOST_DISPLAY, + default: "Please confirm your email address with #{Gemcutter::HOST_DISPLAY}") do |format| + format.html + format.text + end end def email_reset_update(user) @user = user mail to: @user.email, - subject: I18n.t("mailer.email_reset_update.subject") + subject: I18n.t("mailer.email_reset_update.subject", host: Gemcutter::HOST_DISPLAY) end def email_confirmation(user) @user = user + + if @user.confirmation_token + mail to: @user.email, + subject: I18n.t("mailer.confirmation_subject", host: Gemcutter::HOST_DISPLAY, + default: "Please confirm your email address with #{Gemcutter::HOST_DISPLAY}") do |format| + format.html + format.text + end + else + Rails.logger.info("[mailer:email_confirmation] confirmation token not found. skipping sending mail for #{@user.handle}") + end + end + + def admin_manual(user, subject, body) + @user = user + @body = body + @sub_title = subject mail to: @user.email, - subject: I18n.t("mailer.confirmation_subject", - default: "Please confirm your email address with RubyGems.org") + subject: subject do |format| + format.html + format.text + end end def deletion_complete(email) @@ -45,21 +60,88 @@ def notifiers_changed(user_id) default: "You changed your RubyGems.org email notification settings") end - def gem_pushed(pushed_by_user_id, version_id, notified_user_id) + def gem_pushed(pushed_by, version_id, notified_user_id) @version = Version.find(version_id) notified_user = User.find(notified_user_id) - @pushed_by_user = User.find(pushed_by_user_id) + @pushed_by_user = pushed_by mail to: notified_user.email, - subject: I18n.t("mailer.gem_pushed.subject", gem: @version.to_title, + subject: I18n.t("mailer.gem_pushed.subject", gem: @version.to_title, host: Gemcutter::HOST_DISPLAY, default: "Gem %{gem} pushed to RubyGems.org") end + def gem_trusted_publisher_added(rubygem_trusted_publisher, created_by_user, notified_user) + @rubygem_trusted_publisher = rubygem_trusted_publisher + @created_by_user = created_by_user + @notified_user = notified_user + + mail to: notified_user.email, + subject: I18n.t("mailer.gem_trusted_publisher_added.subject", + gem: @rubygem_trusted_publisher.rubygem.name, + host: Gemcutter::HOST_DISPLAY, + default: "Trusted publisher added to %{gem} on RubyGems.org") + end + def mfa_notification(user_id) @user = User.find(user_id) mail to: @user.email, - subject: "Please consider enabling MFA for your account" + subject: "Please consider enabling multi-factor authentication for your account" + end + + def mfa_recommendation_announcement(user_id) + @user = User.find(user_id) + + mail to: @user.email, + subject: "Please enable multi-factor authentication on your RubyGems account" + end + + def mfa_required_soon_announcement(user_id) + @user = User.find(user_id) + @heading = mfa_required_soon_heading(@user.mfa_level) + + mail to: @user.email, + subject: mfa_required_soon_subject(@user.mfa_level) + end + + def mfa_required_popular_gems_announcement(user_id) + @user = User.find(user_id) + @heading = mfa_required_popular_gems_heading(@user.mfa_level) + + mail to: @user.email, + subject: mfa_required_popular_gems_subject(@user.mfa_level) + end + + def webauthn_credential_created(webauthn_credential_id) + @webauthn_credential = WebauthnCredential.find(webauthn_credential_id) + + mail to: @webauthn_credential.user.email, + subject: I18n.t("mailer.webauthn_credential_created.subject", host: Gemcutter::HOST_DISPLAY) + end + + def webauthn_credential_removed(user_id, nickname, deleted_at) + @user = User.find(user_id) + @nickname = nickname + @deleted_at = deleted_at + + mail to: @user.email, + subject: I18n.t("mailer.webauthn_credential_removed.subject", host: Gemcutter::HOST_DISPLAY) + end + + def totp_enabled(user_id, enabled_at) + @user = User.find(user_id) + @enabled_at = enabled_at + + mail to: @user.email, + subject: I18n.t("mailer.totp_enabled.subject", host: Gemcutter::HOST_DISPLAY) + end + + def totp_disabled(user_id, disabled_at) + @user = User.find(user_id) + @disabled_at = disabled_at + + mail to: @user.email, + subject: I18n.t("mailer.totp_disabled.subject", host: Gemcutter::HOST_DISPLAY) end def gem_yanked(yanked_by_user_id, version_id, notified_user_id) @@ -68,13 +150,13 @@ def gem_yanked(yanked_by_user_id, version_id, notified_user_id) @yanked_by_user = User.find(yanked_by_user_id) mail to: notified_user.email, - subject: I18n.t("mailer.gem_yanked.subject", gem: @version.to_title) + subject: I18n.t("mailer.gem_yanked.subject", gem: @version.to_title, host: Gemcutter::HOST_DISPLAY) end def reset_api_key(user, template_name) @user = user mail to: @user.email, - subject: I18n.t("mailer.reset_api_key.subject"), + subject: I18n.t("mailer.reset_api_key.subject", host: Gemcutter::HOST_DISPLAY), template_name: template_name end @@ -84,4 +166,13 @@ def api_key_created(api_key_id) mail to: @api_key.user.email, subject: I18n.t("mail.api_key_created.subject", default: "New API key created for rubygems.org") end + + def api_key_revoked(user_id, api_key_name, enabled_scopes, commit_url) + @commit_url = commit_url + @user = User.find(user_id) + @api_key_name = api_key_name + @enabled_scopes = enabled_scopes + mail to: @user.email, + subject: I18n.t("mail.api_key_revoked.subject", default: "One of your API keys was revoked on rubygems.org") + end end diff --git a/app/mailers/owners_mailer.rb b/app/mailers/owners_mailer.rb index 4ae5ef6a95e..2130ee0abcb 100644 --- a/app/mailers/owners_mailer.rb +++ b/app/mailers/owners_mailer.rb @@ -1,20 +1,27 @@ class OwnersMailer < ApplicationMailer - include Roadie::Rails::Automatic - include OwnersHelper helper :owners - default from: Clearance.configuration.mailer_sender - - default_url_options[:host] = Gemcutter::HOST - default_url_options[:protocol] = Gemcutter::PROTOCOL - - def ownership_confirmation(ownership_id) - @ownership = Ownership.find(ownership_id) + def ownership_confirmation(ownership) + @ownership = ownership @user = @ownership.user @rubygem = @ownership.rubygem mail to: @user.email, - subject: t("mailer.ownership_confirmation.subject", gem: @rubygem.name) + subject: t("mailer.ownership_confirmation.subject", gem: @rubygem.name, host: Gemcutter::HOST_DISPLAY) do |format| + format.html + format.text + end + end + + def owner_updated + @ownership = params[:ownership] + @user = @ownership.user + @rubygem = @ownership.rubygem + + mail( + to: @user.email, + subject: t("mailer.owner_updated.subject", gem: @rubygem.name, host: Gemcutter::HOST_DISPLAY) + ) end def owner_removed(user_id, remover_id, gem_id) @@ -33,4 +40,28 @@ def owner_added(user_id, owner_id, authorizer_id, gem_id) mail to: @user.email, subject: t("mailer.owner_added.subject_#{owner_i18n_key(@owner, @user)}", gem: @rubygem.name, owner_handle: @owner.display_handle) end + + def new_ownership_requests(rubygem_id, user_id) + @user = User.find(user_id) + @rubygem = Rubygem.find(rubygem_id) + @ownership_requests_count = @rubygem.ownership_requests.opened.count + mail to: @user.email, + subject: "New ownership request(s) for #{@rubygem.name}" + end + + def ownership_request_approved(ownership_request_id) + @ownership_request = OwnershipRequest.find(ownership_request_id) + @rubygem = @ownership_request.rubygem + @user = @ownership_request.user + mail to: @user.email, + subject: "Your ownership request was approved." + end + + def ownership_request_closed(ownership_request_id) + @ownership_request = OwnershipRequest.find(ownership_request_id) + @rubygem = @ownership_request.rubygem + @user = @ownership_request.user + mail to: @user.email, + subject: "Your ownership request was closed." + end end diff --git a/app/mailers/password_mailer.rb b/app/mailers/password_mailer.rb new file mode 100644 index 00000000000..06ff3d6eb4e --- /dev/null +++ b/app/mailers/password_mailer.rb @@ -0,0 +1,11 @@ +class PasswordMailer < ApplicationMailer + def change_password(user) + @user = User.find(user["id"]) + mail from: Clearance.configuration.mailer_sender, + to: @user.email, + subject: I18n.t("clearance.models.clearance_mailer.change_password") do |format| + format.html + format.text + end + end +end diff --git a/app/mailers/web_hooks_mailer.rb b/app/mailers/web_hooks_mailer.rb new file mode 100644 index 00000000000..4be2cc2f45f --- /dev/null +++ b/app/mailers/web_hooks_mailer.rb @@ -0,0 +1,25 @@ +class WebHooksMailer < ApplicationMailer + def webhook_deleted(user_id, rubygem_id, url, failure_count) + @user = User.find(user_id) + @rubygem = Rubygem.find(rubygem_id) if rubygem_id + @url = url + @failure_count = failure_count + + mail to: @user.email, + subject: t("mailer.web_hook_deleted.subject") do |format| + format.html + format.text + end + end + + def webhook_disabled(web_hook) + @web_hook = web_hook + @delete_command = "gem install gemcutter && gem webhook#{" #{web_hook.rubygem.name}" unless web_hook.global?} --remove '#{web_hook.url}'" + + mail to: web_hook.user.email, + subject: t("mailer.web_hook_disabled.subject") do |format| + format.html + format.text + end + end +end diff --git a/app/models/admin.rb b/app/models/admin.rb new file mode 100644 index 00000000000..bc3ce234476 --- /dev/null +++ b/app/models/admin.rb @@ -0,0 +1,5 @@ +module Admin + def self.table_name_prefix + "admin_" + end +end diff --git a/app/models/admin/github_user.rb b/app/models/admin/github_user.rb new file mode 100644 index 00000000000..2c42dacc9e4 --- /dev/null +++ b/app/models/admin/github_user.rb @@ -0,0 +1,44 @@ +class Admin::GitHubUser < ApplicationRecord + has_many :audits, inverse_of: :admin_github_user, foreign_key: :admin_github_user_id, dependent: :restrict_with_exception + + scope :admins, -> { where(is_admin: true) } + + # for avo + alias_attribute :name, :login + alias_attribute :avatar, :avatar_url + + validate :valid_if_admin + validates :login, presence: true + validates :github_id, presence: true, uniqueness: true + validates :info_data, presence: true + + def teams + info_data.dig(:viewer, :organization, :teams, :edges)&.pluck(:node) || [] + end + + def team_member?(slug) + teams.any? { |team| team[:slug] == slug } + end + + def valid_if_admin + return unless is_admin + + errors.add(:is_admin, "missing oauth token") if oauth_token.blank? + errors.add(:is_admin, "missing info data") if info_data.blank? + errors.add(:is_admin, "missing viewer login") if info_data.dig(:viewer, :login).blank? + errors.add(:is_admin, "missing rubygems org") if info_data.dig(:viewer, :organization, :login) != "rubygems" + errors.add(:is_admin, "not a member of the rubygems org") unless info_data.dig(:viewer, :organization, :viewerIsAMember) + end + + def info_data=(info_data) + info_data = info_data&.deep_symbolize_keys || {} + super + self.login = info_data.dig(:viewer, :login) + self.github_id = info_data.dig(:viewer, :id) + self.avatar_url = info_data.dig(:viewer, :avatarUrl) + end + + def info_data + super&.deep_symbolize_keys + end +end diff --git a/app/models/api_key.rb b/app/models/api_key.rb index a9fe94b5468..9a41d74fa29 100644 --- a/app/models/api_key.rb +++ b/app/models/api_key.rb @@ -1,21 +1,130 @@ class ApiKey < ApplicationRecord - API_SCOPES = %i[index_rubygems push_rubygem yank_rubygem add_owner remove_owner access_webhooks show_dashboard].freeze + class ScopeError < RuntimeError; end - belongs_to :user - validates :user, :name, :hashed_key, presence: true + API_SCOPES = %i[show_dashboard index_rubygems push_rubygem yank_rubygem add_owner update_owner remove_owner access_webhooks + configure_trusted_publishers].freeze + APPLICABLE_GEM_API_SCOPES = %i[push_rubygem yank_rubygem add_owner update_owner remove_owner configure_trusted_publishers].freeze + EXCLUSIVE_SCOPES = %i[show_dashboard].freeze + + self.ignored_columns += API_SCOPES + + belongs_to :owner, polymorphic: true + + has_one :api_key_rubygem_scope, dependent: :destroy + has_one :ownership, through: :api_key_rubygem_scope + has_one :oidc_id_token, class_name: "OIDC::IdToken", dependent: :restrict_with_error + has_one :oidc_api_key_role, class_name: "OIDC::ApiKeyRole", through: :oidc_id_token, source: :api_key_role, inverse_of: :api_keys + has_many :pushed_versions, class_name: "Version", inverse_of: :pusher_api_key, foreign_key: :pusher_api_key_id, dependent: :nullify + + before_validation :set_owner_from_user + after_create :record_create_event + after_update :record_expire_event, if: :saved_change_to_expires_at? + + validates :name, :hashed_key, presence: true validate :exclusive_show_dashboard_scope, if: :can_show_dashboard? validate :scope_presence validates :name, length: { maximum: Gemcutter::MAX_FIELD_LENGTH } + validates :expires_at, inclusion: { in: -> { 1.minute.from_now.. } }, allow_nil: true, on: :create + validate :rubygem_scope_definition, if: :ownership + validate :known_scopes + validate :not_soft_deleted? + validate :not_expired? + + delegate :rubygem_id, :rubygem, to: :ownership, allow_nil: true + + scope :unexpired, -> { where(arel_table[:expires_at].eq(nil).or(arel_table[:expires_at].gt(Time.now.utc))) } + scope :expired, -> { where(arel_table[:expires_at].lteq(Time.now.utc)) } - def enabled_scopes - API_SCOPES.map { |scope| scope if send(scope) }.compact + scope :oidc, -> { joins(:oidc_id_token) } + scope :not_oidc, -> { where.missing(:oidc_id_token) } + + def self.expire_all! + transaction do + unexpired.find_each.all?(&:expire!) + end + end + + def scope?(scope, scoped_gem = nil) + scope_enabled = scopes.include?(scope) + # TODO: once all calls to scope checks are changed, this check should + # fail if the api_key has a rubygem but scope? is called without a scoped_gem + scope_enabled = false if scoped_gem && rubygem_id && rubygem_id != scoped_gem.id + return scope_enabled if !scope_enabled || new_record? + touch :last_accessed_at end API_SCOPES.each do |scope| - define_method(:"can_#{scope}?") do - scope_enabled = send(scope) - return scope_enabled if !scope_enabled || new_record? - touch :last_accessed_at + define_method(:"can_#{scope}?") { scope?(scope) } + alias_method scope, :"can_#{scope}?" + end + + def scopes + super&.map(&:to_sym) || [] + end + + def user + owner if user? + end + + def user? + owner_type == "User" + end + + delegate :mfa_required_not_yet_enabled?, :mfa_required_weak_level_enabled?, + :mfa_recommended_not_yet_enabled?, :mfa_recommended_weak_level_enabled?, + to: :user, allow_nil: true + + def mfa_authorized?(otp) + return true unless mfa_enabled? + return true if oidc_id_token.present? + user.api_mfa_verified?(otp) + end + + def mfa_enabled? + return false unless user? + return false unless user.mfa_enabled? + return false if short_lived? + user.mfa_ui_and_api? || mfa + end + + def short_lived? + return false unless created_at && expires_at + (expires_at - created_at) < 15.minutes + end + + def rubygem_id=(id) + self.ownership = id.blank? ? nil : user.ownerships.find_by!(rubygem_id: id) + rescue ActiveRecord::RecordNotFound + errors.add :rubygem, "must be a gem that you are an owner of" + end + + def rubygem_name=(name) + self.rubygem_id = name.blank? ? nil : Rubygem.find_by_name!(name).id + rescue ActiveRecord::RecordNotFound + errors.add :rubygem, "could not be found" + end + + def soft_delete!(ownership: nil) + update_attribute(:soft_deleted_at, Time.now.utc) + update_attribute(:soft_deleted_rubygem_name, ownership.rubygem.name) if ownership + end + + def soft_deleted? + soft_deleted_at? + end + + def soft_deleted_by_ownership? + soft_deleted? && soft_deleted_rubygem_name.present? + end + + def expired? + expires_at && expires_at <= Time.now.utc + end + + def expire! + transaction do + update_column(:expires_at, Time.current) + record_expire_event end end @@ -26,10 +135,48 @@ def exclusive_show_dashboard_scope end def other_enabled_scopes? - enabled_scopes.tap { |scope| scope.delete(:show_dashboard) }.any? + scopes.-(%i[show_dashboard]).any? end def scope_presence - errors.add :base, "Please enable at least one scope" unless enabled_scopes.any? + errors.add :base, "Please enable at least one scope" if scopes.blank? + end + + def rubygem_scope_definition + return if APPLICABLE_GEM_API_SCOPES.intersect?(scopes) + errors.add :rubygem, "scope can only be set for push/yank rubygem, and add/remove owner scopes" + end + + def known_scopes + errors.add :scopes, "scopes must be from #{API_SCOPES}, got: #{scopes}" if (scopes - API_SCOPES).present? + end + + def not_soft_deleted? + errors.add :base, "An invalid API key cannot be used. Please delete it and create a new one." if soft_deleted? + end + + def not_expired? + return false if changed == %w[expires_at] + errors.add :base, "An expired API key cannot be used. Please create a new one." if expired? + end + + def set_owner_from_user + self.owner ||= user + end + + def record_create_event + case owner + when User + user.record_event!(Events::UserEvent::API_KEY_CREATED, + name:, scopes:, gem: rubygem&.name, mfa:, api_key_gid: to_gid) + end + end + + def record_expire_event + case owner + when User + user.record_event!(Events::UserEvent::API_KEY_DELETED, + name:, api_key_gid: to_gid) + end end end diff --git a/app/models/api_key_rubygem_scope.rb b/app/models/api_key_rubygem_scope.rb new file mode 100644 index 00000000000..cb207009cf9 --- /dev/null +++ b/app/models/api_key_rubygem_scope.rb @@ -0,0 +1,13 @@ +class ApiKeyRubygemScope < ApplicationRecord + belongs_to :api_key + belongs_to :ownership + validates :ownership_id, uniqueness: { scope: :api_key_id } + validates :api_key, :ownership, presence: true + before_destroy :soft_delete_api_key!, if: :destroyed_by_association + + private + + def soft_delete_api_key! + api_key.soft_delete!(ownership: ownership) + end +end diff --git a/app/models/application_model.rb b/app/models/application_model.rb new file mode 100644 index 00000000000..aaf7e6e8802 --- /dev/null +++ b/app/models/application_model.rb @@ -0,0 +1,83 @@ +class ApplicationModel + include ActiveModel::Model + include ActiveModel::Attributes + include ActiveModel::Serializers::JSON + + include SemanticLogger::Loggable + + # Taken from ActiveRecord::Base + concerning "Inspectable" do + included do + # Returns the contents of the record as a nicely formatted string. + def inspect + # We check defined?(@attributes) not to issue warnings if the object is + # allocated but not initialized. + inspection = if defined?(@attributes) && @attributes + attribute_names.filter_map do |name| + "#{name}: #{_read_attribute(name)}" if @attributes.key?(name) + end.join(", ") + else + "not initialized" + end + + "#<#{self.class} #{inspection}>" + end + + # Takes a PP and prettily prints this record to it, allowing you to get a nice result from pp record + # when pp is required. + def pretty_print(pp) + pp.object_address_group(self) do + if defined?(@attributes) && @attributes + attr_names = attribute_names.select { |name| @attributes.key?(name) } + pp.seplist(attr_names, proc { pp.text "," }) do |attr_name| + pp.breakable " " + pp.group(1) do + pp.text attr_name + pp.text ":" + pp.breakable + value = _read_attribute(attr_name) + pp.pp value + end + end + else + pp.breakable " " + pp.text "not initialized" + end + end + end + end + end + + concerning "Attributes" do + included do + def [](attr_name) + _read_attribute(attr_name) { |n| missing_attribute(n, caller) } + end + + def has_attribute?(attr_name) # rubocop:disable Naming/PredicateName + @attributes.key?(attr_name.to_s) + end + end + end + + concerning "Equality" do + included do + def ==(other) + self.class == other.class && + ((attributes.keys | other.attributes.keys).all? do |k| + self[k] == other[k] + end) + end + + alias_method :eql?, :== + end + end + + concerning "Hashing" do + included do + alias_method :eql?, :== + + delegate :hash, to: :attributes + end + end +end diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 10a4cba84df..b72b147fbcb 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -1,3 +1,5 @@ class ApplicationRecord < ActiveRecord::Base + include SemanticLogger::Loggable + self.abstract_class = true end diff --git a/app/models/audit.rb b/app/models/audit.rb new file mode 100644 index 00000000000..f806bcf94bc --- /dev/null +++ b/app/models/audit.rb @@ -0,0 +1,9 @@ +class Audit < ApplicationRecord + belongs_to :auditable, polymorphic: true + belongs_to :admin_github_user, class_name: "Admin::GitHubUser" + + serialize :audited_changes, coder: JSON + + validates :action, presence: true + validates :auditable, presence: false +end diff --git a/app/models/concerns/rubygem_searchable.rb b/app/models/concerns/rubygem_searchable.rb index 7c09d586e96..27eee6ad34f 100644 --- a/app/models/concerns/rubygem_searchable.rb +++ b/app/models/concerns/rubygem_searchable.rb @@ -2,15 +2,36 @@ module RubygemSearchable extend ActiveSupport::Concern included do - include Elasticsearch::Model - - index_name "rubygems-#{Rails.env}" - - delegate :index_document, to: :__elasticsearch__ - delegate :update_document, to: :__elasticsearch__ + searchkick index_name: Gemcutter::SEARCH_INDEX_NAME, + callbacks: false, + settings: { + number_of_shards: 1, + number_of_replicas: Gemcutter::SEARCH_NUM_REPLICAS, + analysis: { + analyzer: { + rubygem: { + type: "pattern", + pattern: "[\s#{Regexp.escape(Patterns::SPECIAL_CHARACTERS)}]+" + } + } + } + }, + mappings: { + properties: { + name: { type: "text", analyzer: "rubygem", + fields: { suggest: { analyzer: "simple", type: "text" }, unanalyzed: { type: "keyword", index: "true" } } }, + summary: { type: "text", analyzer: "english", fields: { raw: { analyzer: "simple", type: "text" } } }, + description: { type: "text", analyzer: "english", fields: { raw: { analyzer: "simple", type: "text" } } }, + suggest: { type: "completion", contexts: { name: "yanked", type: "category" } }, + yanked: { type: "boolean" }, + downloads: { type: "long" }, + updated: { type: "date" } + } + } + scope :search_import, -> { includes(:linkset, :gem_download, :most_recent_version, :versions, :latest_version) } - def as_indexed_json(_options = {}) # rubocop:disable Metrics/MethodLength - if (latest_version = versions.most_recent) + def search_data # rubocop:disable Metrics/MethodLength + if (latest_version = most_recent_version) deps = latest_version.dependencies.to_a versioned_links = links(latest_version) end @@ -27,7 +48,7 @@ def as_indexed_json(_options = {}) # rubocop:disable Metrics/MethodLength metadata: latest_version&.metadata, sha: latest_version&.sha256_hex, project_uri: "#{Gemcutter::PROTOCOL}://#{Gemcutter::HOST}/gems/#{name}", - gem_uri: "#{Gemcutter::PROTOCOL}://#{Gemcutter::HOST}/gems/#{latest_version&.full_name}.gem", + gem_uri: "#{Gemcutter::PROTOCOL}://#{Gemcutter::HOST}/gems/#{latest_version&.gem_file_name}", homepage_uri: versioned_links&.homepage_uri, wiki_uri: versioned_links&.wiki_uri, documentation_uri: versioned_links&.documentation_uri, @@ -44,38 +65,11 @@ def as_indexed_json(_options = {}) # rubocop:disable Metrics/MethodLength development: deps&.select { |r| r.rubygem && r.scope == "development" }, runtime: deps&.select { |r| r.rubygem && r.scope == "runtime" } } - } - end - - settings number_of_shards: 1, - number_of_replicas: 1, - analysis: { - analyzer: { - rubygem: { - type: "pattern", - pattern: "[\s#{Regexp.escape(Patterns::SPECIAL_CHARACTERS)}]+" - } - } - } - - mapping do - indexes :name, type: "text", analyzer: "rubygem" do - indexes :suggest, analyzer: "simple" - indexes :unanalyzed, type: "keyword", index: "true" - end - indexes :summary, type: "text", analyzer: "english" do - indexes :raw, analyzer: "simple" - end - indexes :description, type: "text", analyzer: "english" do - indexes :raw, analyzer: "simple" - end - indexes :yanked, type: "boolean" - indexes :downloads, type: "integer" - indexes :updated, type: "date" + }.merge!(suggest_json) end def self.legacy_search(query) - conditions = <<-SQL + conditions = <<~SQL.squish versions.indexed and (UPPER(name) LIKE UPPER(:query) OR UPPER(TRANSLATE(name, :match, :replace)) LIKE UPPER(:query)) @@ -87,5 +81,23 @@ def self.legacy_search(query) .references(:versions) .by_downloads end + + private + + def suggest_json + { + suggest: { + input: name, + weight: suggest_weight_scale(downloads), + contexts: { + yanked: versions.none?(&:indexed?) + } + } + } + end + + def suggest_weight_scale(downloads) + Math.log10(downloads + 1).to_i + end end end diff --git a/app/models/concerns/user_multifactor_methods.rb b/app/models/concerns/user_multifactor_methods.rb new file mode 100644 index 00000000000..07a687e532e --- /dev/null +++ b/app/models/concerns/user_multifactor_methods.rb @@ -0,0 +1,114 @@ +module UserMultifactorMethods + extend ActiveSupport::Concern + + included do + include UserTotpMethods + include UserWebauthnMethods + + attr_accessor :new_mfa_recovery_codes + + enum :mfa_level, { disabled: 0, ui_only: 1, ui_and_api: 2, ui_and_gem_signin: 3 }, prefix: :mfa + + validate :mfa_level_for_enabled_devices + end + + def mfa_enabled? + !mfa_disabled? + end + + def mfa_device_count_one? + (totp_disabled? && webauthn_credentials.count == 1) || (totp_enabled? && webauthn_disabled?) + end + + def no_mfa_devices? + totp_disabled? && webauthn_disabled? + end + + def mfa_devices_present? + !no_mfa_devices? + end + + def mfa_gem_signin_authorized?(otp) + return true unless strong_mfa_level? + api_mfa_verified?(otp) + end + + def mfa_recommended_not_yet_enabled? + mfa_recommended? && mfa_disabled? + end + + def mfa_recommended_weak_level_enabled? + mfa_recommended? && mfa_ui_only? + end + + def mfa_required_not_yet_enabled? + mfa_required? && mfa_disabled? + end + + def mfa_required_weak_level_enabled? + mfa_required? && mfa_ui_only? + end + + def ui_mfa_verified?(otp) + otp = otp.to_s + return true if verify_totp(totp_seed, otp) + + return false unless verify_mfa_recovery_code(otp) + + save!(validate: false) + end + + def api_mfa_verified?(otp) + return true if verify_webauthn_otp(otp) + return true if ui_mfa_verified?(otp) + false + end + + def mfa_method_added(default_level) + return unless mfa_device_count_one? + + self.mfa_level = default_level + self.new_mfa_recovery_codes = Array.new(10).map { SecureRandom.hex(6) } + self.mfa_hashed_recovery_codes = new_mfa_recovery_codes.map { |code| BCrypt::Password.create(code) } + end + + private + + def strong_mfa_level? + mfa_ui_and_gem_signin? || mfa_ui_and_api? + end + + def mfa_recommended? + return false if strong_mfa_level? || mfa_required? + + rubygems.mfa_recommended.any? + end + + def mfa_required? + return false if strong_mfa_level? + + rubygems.mfa_required.any? + end + + def mfa_level_for_enabled_devices + return if correct_mfa_level_set_conditions + + errors.add(:mfa_level, :invalid) + end + + def correct_mfa_level_set_conditions + (mfa_disabled? && no_mfa_devices?) || (mfa_enabled? && mfa_devices_present?) + end + + def verify_mfa_recovery_code(otp) + hashed_code = mfa_hashed_recovery_codes.find { |code| BCrypt::Password.new(code) == otp } + return unless hashed_code + mfa_hashed_recovery_codes.delete(hashed_code) + end + + class_methods do + def without_mfa + where(mfa_level: "disabled") + end + end +end diff --git a/app/models/concerns/user_totp_methods.rb b/app/models/concerns/user_totp_methods.rb new file mode 100644 index 00000000000..0aa94bdbb26 --- /dev/null +++ b/app/models/concerns/user_totp_methods.rb @@ -0,0 +1,53 @@ +module UserTotpMethods + extend ActiveSupport::Concern + + def totp_enabled? + totp_seed.present? + end + + def totp_disabled? + totp_seed.blank? + end + + def disable_totp! + self.totp_seed = nil + + if no_mfa_devices? + self.mfa_level = "disabled" + self.mfa_hashed_recovery_codes = [] + end + + save!(validate: false) + Mailer.totp_disabled(id, Time.now.utc).deliver_later + end + + def verify_and_enable_totp!(seed, level, otp, expiry) + if expiry < Time.now.utc + errors.add(:base, I18n.t("totps.create.qrcode_expired")) + elsif verify_totp(seed, otp) + enable_totp!(seed, level) + else + errors.add(:base, I18n.t("totps.incorrect_otp")) + end + end + + def enable_totp!(seed, level) + self.totp_seed = seed + + mfa_method_added(level) + + save!(validate: false) + Mailer.totp_enabled(id, Time.now.utc).deliver_later + end + + private + + def verify_totp(seed, otp) + return false if seed.blank? + + totp = ROTP::TOTP.new(seed) + return false unless totp.verify(otp, drift_behind: 30, drift_ahead: 30) + + save!(validate: false) + end +end diff --git a/app/models/concerns/user_webauthn_methods.rb b/app/models/concerns/user_webauthn_methods.rb new file mode 100644 index 00000000000..add3d95930a --- /dev/null +++ b/app/models/concerns/user_webauthn_methods.rb @@ -0,0 +1,56 @@ +module UserWebauthnMethods + extend ActiveSupport::Concern + + included do + has_many :webauthn_credentials, dependent: :destroy + has_one :webauthn_verification, dependent: :destroy + + after_initialize do + self.webauthn_id ||= WebAuthn.generate_user_id + end + end + + def webauthn_options_for_create + WebAuthn::Credential.options_for_create( + user: { + id: webauthn_id, + name: display_id + }, + exclude: webauthn_credentials.pluck(:external_id), + authenticator_selection: { user_verification: "discouraged", resident_key: "preferred" } + ) + end + + def webauthn_enabled? + webauthn_credentials.present? + end + + def webauthn_disabled? + webauthn_credentials.none? + end + + def webauthn_only_with_recovery? + webauthn_enabled? && totp_disabled? && mfa_hashed_recovery_codes.present? + end + + def webauthn_options_for_get + WebAuthn::Credential.options_for_get( + allow: webauthn_credentials.pluck(:external_id), + user_verification: "discouraged" + ) + end + + def refresh_webauthn_verification + self.webauthn_verification = WebauthnVerification.create( + path_token: SecureRandom.base58(16), + path_token_expires_at: 2.minutes.from_now, + user_id: id + ) + end + + private + + def verify_webauthn_otp(otp) + webauthn_verification&.verify_otp(otp) + end +end diff --git a/app/models/current.rb b/app/models/current.rb new file mode 100644 index 00000000000..e92a18d07d4 --- /dev/null +++ b/app/models/current.rb @@ -0,0 +1,4 @@ +class Current < ActiveSupport::CurrentAttributes + attribute :request + attribute :user +end diff --git a/app/models/deletion.rb b/app/models/deletion.rb index 12cf494ae70..d6bea4e4703 100644 --- a/app/models/deletion.rb +++ b/app/models/deletion.rb @@ -1,39 +1,85 @@ class Deletion < ApplicationRecord - belongs_to :user + # we nullify the user when they delete their account + belongs_to :user, optional: true - validates :user, :rubygem, :number, presence: true + belongs_to :version, inverse_of: :deletion + + validates :user, presence: true, on: :create + validates :rubygem, :number, presence: true validates :version, presence: true - validate :version_is_indexed + validate :version_is_indexed, on: :create + validate :eligibility, on: :create + validate :metadata_matches_version before_validation :record_metadata after_create :remove_from_index, :set_yanked_info_checksum + after_create :record_yank_event + after_destroy :record_unyank_event after_commit :remove_from_storage, on: :create + after_commit :remove_version_contents, on: :create after_commit :expire_cache after_commit :update_search_index - after_commit :send_gem_yanked_mail + after_commit :send_gem_yanked_mail, on: :create - attr_accessor :version + attr_accessor :force def restore! restore_to_index restore_to_storage + restore_version_contents destroy! end + def ineligible? + ineligible_reason.present? + end + + def ineligible_reason + if version.created_at&.before? 30.days.ago + "Versions published more than 30 days ago cannot be deleted." + elsif version.downloads_count > 100_000 + "Versions with more than 100,000 downloads cannot be deleted." + end + end + + def record_yank_forbidden_event! + return unless user && version && version.indexed? && ineligible? + version.rubygem.record_event!( + Events::RubygemEvent::VERSION_YANK_FORBIDDEN, + reason: ineligible_reason, + number: version.number, + platform: version.platform, + yanked_by: user.display_handle, + actor_gid: user.to_gid, + version_gid: version.to_gid + ) + end + private def version_is_indexed - errors.add(:base, "#{rubygem_name} #{version} has already been deleted") unless @version.indexed? + errors.add(:base, "#{rubygem_name} #{version} has already been deleted") unless version.indexed? + end + + def eligibility + return if force + errors.add(:base, ineligible_reason) if ineligible? + end + + def metadata_matches_version + errors.add(:rubygem, "does not match version rubygem name") unless rubygem == version.rubygem.name + errors.add(:number, "does not match version number") unless number == version.number + errors.add(:platform, "does not match version platform") unless platform == version.platform end def rubygem_name - @version.rubygem.name + version.rubygem.name end def record_metadata self.rubygem = rubygem_name - self.number = @version.number - self.platform = @version.platform + self.number = version.number + self.platform = version.platform end def expire_cache @@ -42,32 +88,51 @@ def expire_cache end def remove_from_index - @version.update!(indexed: false, yanked_at: Time.now.utc) - Delayed::Job.enqueue Indexer.new, priority: PRIORITIES[:push] + version.update!(indexed: false, yanked_at: Time.now.utc) + reindex + Rstuf::RemoveJob.perform_later(version:) end def restore_to_index version.update!(indexed: true, yanked_at: nil, yanked_info_checksum: nil) - Delayed::Job.enqueue Indexer.new, priority: PRIORITIES[:push] + reindex + Rstuf::AddJob.perform_later(version:) + end + + def reindex + Indexer.perform_later + UploadInfoFileJob.perform_later(rubygem_name: rubygem_name) + UploadVersionsFileJob.perform_later + UploadNamesFileJob.perform_later end def remove_from_storage - RubygemFs.instance.remove("gems/#{@version.full_name}.gem") - RubygemFs.instance.remove("quick/Marshal.4.8/#{@version.full_name}.gemspec.rz") + RubygemFs.instance.remove( + "gems/#{version.gem_file_name}", + "quick/Marshal.4.8/#{version.full_name}.gemspec.rz" + ) end def restore_to_storage - RubygemFs.instance.restore("gems/#{@version.full_name}.gem") - RubygemFs.instance.restore("quick/Marshal.4.8/#{@version.full_name}.gemspec.rz") + RubygemFs.instance.restore("gems/#{version.gem_file_name}") + RubygemFs.instance.restore("quick/Marshal.4.8/#{version.full_name}.gemspec.rz") + end + + def remove_version_contents + YankVersionContentsJob.perform_later(version:) + end + + def restore_version_contents + StoreVersionContentsJob.perform_later(version:) end def purge_fastly - Fastly.delay.purge("gems/#{@version.full_name}.gem") - Fastly.delay.purge("quick/Marshal.4.8/#{@version.full_name}.gemspec.rz") + FastlyPurgeJob.perform_later(path: "gems/#{version.gem_file_name}", soft: false) + FastlyPurgeJob.perform_later(path: "quick/Marshal.4.8/#{version.full_name}.gemspec.rz", soft: false) end def update_search_index - @version.rubygem.delay.update_document + ReindexRubygemJob.perform_later(rubygem: version.rubygem) end def set_yanked_info_checksum @@ -77,7 +142,17 @@ def set_yanked_info_checksum def send_gem_yanked_mail version.rubygem.push_notifiable_owners.each do |notified_user| - Mailer.delay.gem_yanked(user.id, version.id, notified_user.id) + Mailer.gem_yanked(user.id, version.id, notified_user.id).deliver_later end end + + def record_yank_event + version.rubygem.record_event!(Events::RubygemEvent::VERSION_YANKED, number: version.number, platform: version.platform, +yanked_by: user&.display_handle, actor_gid: user&.to_gid, version_gid: version.to_gid, force:) + end + + def record_unyank_event + version.rubygem.record_event!(Events::RubygemEvent::VERSION_UNYANKED, number: version.number, platform: version.platform, +version_gid: version.to_gid) + end end diff --git a/app/models/dependency.rb b/app/models/dependency.rb index f629c87dff9..ccd2868e17e 100644 --- a/app/models/dependency.rb +++ b/app/models/dependency.rb @@ -1,13 +1,15 @@ class Dependency < ApplicationRecord belongs_to :rubygem, optional: true belongs_to :version + has_one :version_rubygem, through: :version, source: :rubygem before_validation :use_gem_dependency, :use_existing_rubygem, :parse_gem_dependency - validates :requirements, length: { maximum: Gemcutter::MAX_FIELD_LENGTH }, presence: true + validates :requirements, length: { maximum: Gemcutter::MAX_FIELD_LENGTH }, gem_requirements: true, presence: true validates :unresolved_name, length: { maximum: Gemcutter::MAX_FIELD_LENGTH }, allow_blank: true + validates :unresolved_name, name_format: true, allow_blank: true, on: :create validates :scope, inclusion: { in: %w[development runtime] } attr_accessor :gem_dependency @@ -40,18 +42,12 @@ def payload } end - def as_json(*) - payload - end + delegate :as_json, :to_yaml, to: :payload def to_xml(options = {}) payload.to_xml(options.merge(root: "dependency")) end - def to_yaml(*args) - payload.to_yaml(*args) - end - def encode_with(coder) coder.tag = nil coder.implicit = true diff --git a/app/models/events.rb b/app/models/events.rb new file mode 100644 index 00000000000..ceecd00076f --- /dev/null +++ b/app/models/events.rb @@ -0,0 +1,5 @@ +module Events + def self.table_name_prefix + "events_" + end +end diff --git a/app/models/events/organization_event.rb b/app/models/events/organization_event.rb new file mode 100644 index 00000000000..8171c3738aa --- /dev/null +++ b/app/models/events/organization_event.rb @@ -0,0 +1,10 @@ +class Events::OrganizationEvent < ApplicationRecord + belongs_to :organization + + include Events::Tags + + CREATED = define_event "organization:created" do + attribute :name, :string + attribute :actor_gid, :global_id + end +end diff --git a/app/models/events/recordable.rb b/app/models/events/recordable.rb new file mode 100644 index 00000000000..4e3fcfda963 --- /dev/null +++ b/app/models/events/recordable.rb @@ -0,0 +1,26 @@ +module Events::Recordable + extend ActiveSupport::Concern + + def record_event!(tag, request: Current.request, **additional) + ip_address = request&.ip_address + geoip_info = ip_address&.geoip_info + + if (user_agent = request&.user_agent.presence) + begin + user_agent_info = Gemcutter::UserAgentParser.call(user_agent) + additional[:user_agent_info] = user_agent_info + rescue Gemcutter::UserAgentParser::UnableToParse + nil + end + end + + event = events.create!(tag:, ip_address:, geoip_info:, additional:, trace_id: Datadog::Tracing.correlation.trace_id) + logger.info("Recorded event #{tag}", record: cache_key, + event: event.as_json, tag:, ip_address: ip_address.as_json, additional: event.additional) + event + end + + included do + has_many :events, class_name: "Events::#{name}Event", dependent: :destroy, inverse_of: model_name.param_key + end +end diff --git a/app/models/events/rubygem_event.rb b/app/models/events/rubygem_event.rb new file mode 100644 index 00000000000..6f4895dd442 --- /dev/null +++ b/app/models/events/rubygem_event.rb @@ -0,0 +1,80 @@ +class Events::RubygemEvent < ApplicationRecord + belongs_to :rubygem + + include Events::Tags + + VERSION_PUSHED = define_event "rubygem:version:pushed" do + attribute :number, :string + attribute :platform, :string + attribute :sha256, :string + + attribute :pushed_by, :string + + attribute :version_gid, :global_id + attribute :actor_gid, :global_id + end + + VERSION_YANKED = define_event "rubygem:version:yanked" do + attribute :number, :string + attribute :platform, :string + + attribute :yanked_by, :string + + attribute :version_gid, :global_id + attribute :actor_gid, :global_id + attribute :force, :boolean + end + + VERSION_YANK_FORBIDDEN = define_event "rubygem:version:yank_forbidden" do + attribute :number, :string + attribute :platform, :string + + attribute :yanked_by, :string + + attribute :version_gid, :global_id + attribute :actor_gid, :global_id + attribute :reason, :string + end + + VERSION_UNYANKED = define_event "rubygem:version:unyanked" do + attribute :number, :string + attribute :platform, :string + + attribute :version_gid, :global_id + end + + OWNER_ADDED = define_event "rubygem:owner:added" do + attribute :owner, :string + attribute :authorizer, :string + + attribute :actor_gid, :global_id + attribute :owner_gid, :global_id + end + + OWNER_CONFIRMED = define_event "rubygem:owner:confirmed" do + attribute :owner, :string + attribute :authorizer, :string + + attribute :actor_gid, :global_id + attribute :owner_gid, :global_id + end + + OWNER_ROLE_UPDATED = define_event "rubygem:owner:role_updated" do + attribute :owner, :string + attribute :updated_by, :string + + attribute :actor_gid, :global_id + attribute :owner_gid, :global_id + + attribute :previous_role, :string + attribute :current_role, :string + end + + OWNER_REMOVED = define_event "rubygem:owner:removed" do + attribute :owner, :string + attribute :removed_by, :string + + attribute :actor_gid, :global_id + attribute :owner_gid, :global_id + end +end diff --git a/app/models/events/tag.rb b/app/models/events/tag.rb new file mode 100644 index 00000000000..8d5c04468b3 --- /dev/null +++ b/app/models/events/tag.rb @@ -0,0 +1,21 @@ +module Events::Tag + module_function + + def additional_name(tag) + parts = tag.split(":") + parts.shift + :"#{parts.join('_').classify}Additional" + end + + def const_name(tag) + parts = tag.split(":") + parts.shift + parts.map!(&:classify).join("::") + end + + def translation_key(tag) + source, subject, *rest = tag.split(":") + return "events.#{source}_event.#{source}.#{subject}" if rest.empty? + "events.#{source}_event.#{subject}.#{subject}_#{rest.join('_')}" + end +end diff --git a/app/models/events/tags.rb b/app/models/events/tags.rb new file mode 100644 index 00000000000..b2f9b991784 --- /dev/null +++ b/app/models/events/tags.rb @@ -0,0 +1,40 @@ +module Events::Tags + extend ActiveSupport::Concern + + def additional_type + tags.fetch(tag, nil) + end + + def additional + additional_type&.new(super) || super + end + + def additional=(value) + super(value&.to_h) + end + + included do + validates :tag, presence: true, inclusion: { in: ->(_) { tags.keys } } + validates :additional, nested: true, allow_nil: true + belongs_to :ip_address, optional: true + belongs_to :geoip_info, optional: true + + cattr_reader(:tags) { {} } + end + + class_methods do + def define_event(tag, &blk) + raise ArgumentError, "Tag #{tag.inspect} already defined #{tags.inspect}" if tags.key?(tag) + + event = Class.new(ApplicationModel) do + attribute :user_agent_info, Types::JsonDeserializable.new(Events::UserAgentInfo) + + class_eval(&blk) if blk + end + const_set(Events::Tag.additional_name(tag), event) + tags[tag] = event + + -tag + end + end +end diff --git a/app/models/events/user_agent_info.rb b/app/models/events/user_agent_info.rb new file mode 100644 index 00000000000..cd6d5359244 --- /dev/null +++ b/app/models/events/user_agent_info.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class Events::UserAgentInfo < ApplicationModel + attribute :installer, :string + attribute :device, :string + attribute :os, :string + attribute :user_agent, :string + attribute :implementation, :string + attribute :system, :string + + def to_s + if installer == "Browser" + browser_name + elsif installer.present? + installer_name + else + "Unknown user agent" + end + end + + private + + def browser_name + return "Unknown browser" if user_agent == "Other" + + parenthetical_name(user_agent, [os, device]) + end + + def installer_name + parenthetical_name(installer, [implementation, system]) + end + + def parenthetical_name(primary, parts) + parenthetical = parts.reject { |part| part == "Other" }.compact_blank.join(" on ").presence + if parenthetical + "#{primary} (#{parenthetical})" + else + primary + end + end +end diff --git a/app/models/events/user_event.rb b/app/models/events/user_event.rb new file mode 100644 index 00000000000..f082f0c95d5 --- /dev/null +++ b/app/models/events/user_event.rb @@ -0,0 +1,47 @@ +class Events::UserEvent < ApplicationRecord + belongs_to :user, class_name: "::User" + + include Events::Tags + + LOGIN_SUCCESS = define_event "user:login:success" do + attribute :two_factor_method, :string + attribute :two_factor_label, :string + attribute :authentication_method, :string + end + EMAIL_SENT = define_event "user:email:sent" do + attribute :to, :string + attribute :from, :string + attribute :subject, :string + attribute :redact_ip, :boolean + attribute :message_id, :string + + attribute :mailer, :string + attribute :action, :string + end + EMAIL_ADDED = define_event "user:email:added" do + attribute :email, :string + end + EMAIL_VERIFIED = define_event "user:email:verified" do + attribute :email, :string + end + CREATED = define_event "user:created" do + attribute :email, :string + end + + API_KEY_CREATED = define_event "user:api_key:created" do + attribute :name, :string + attribute :scopes, Types::ArrayOf.new(ActiveRecord::Type::String.new) + attribute :gem, :string + attribute :mfa, :boolean + + attribute :api_key_gid, :global_id + end + + API_KEY_DELETED = define_event "user:api_key:deleted" do + attribute :name, :string + + attribute :api_key_gid, :global_id + end + + PASSWORD_CHANGED = define_event "user:password:changed" +end diff --git a/app/models/gem_dependent.rb b/app/models/gem_dependent.rb deleted file mode 100644 index 3a1f4c2ec78..00000000000 --- a/app/models/gem_dependent.rb +++ /dev/null @@ -1,75 +0,0 @@ -class GemDependent - extend StatsD::Instrument - DepKey = Struct.new(:name, :number, :platform) - - attr_reader :gem_names - - def initialize(gem_names) - @gem_information = {} - @gem_names = gem_names - end - - def fetch_dependencies - @gem_names.each { |g| @gem_information[g] = "deps/v1/#{g}" } - - @gem_information.flat_map do |gem_name, cache_key| - if (dependency = memcached_gem_info[cache_key]) - # Fetch the gem's dependencies from the cache - StatsD.increment "gem_dependent.memcached.hit" - else - # Fetch the gem's dependencies from the database - StatsD.increment "gem_dependent.memcached.miss" - dependency = fetch_dependency_from_db(gem_name) - Rails.cache.write(cache_key, dependency) - memcached_gem_info[cache_key] = dependency - end - - dependency - end - end - - alias to_a fetch_dependencies - - private - - def fetch_dependency_from_db(gem_name) - sanitize_sql = ActiveRecord::Base.send(:sanitize_sql_array, sql_query(gem_name)) - dataset = ActiveRecord::Base.connection.execute(sanitize_sql) - deps = {} - - dataset.each do |row| - key = DepKey.new(row["name"], row["number"], row["platform"]) - deps[key] = [] unless deps[key] - deps[key] << [row["dep_name"], row["requirements"]] if row["dep_name"] - end - - deps.map do |dep_key, gem_deps| - { - name: dep_key.name, - number: dep_key.number, - platform: dep_key.platform, - dependencies: gem_deps - } - end - end - statsd_measure :fetch_dependency_from_db, "gem_dependent.fetch_dependency_from_db" - - def sql_query(gem_name) - ["SELECT rv.name, rv.number, rv.platform, d.requirements, for_dep_name.name dep_name - FROM - (SELECT r.name, v.number, v.platform, v.id AS version_id - FROM rubygems AS r, versions AS v - WHERE v.rubygem_id = r.id - AND v.indexed is true AND r.name = ?) AS rv - LEFT JOIN dependencies AS d ON - d.version_id = rv.version_id - LEFT JOIN rubygems AS for_dep_name ON - d.rubygem_id = for_dep_name.id - AND d.scope = 'runtime'", gem_name] - end - - # Returns a Hash of the gem's cache key, and its cached dependencies - def memcached_gem_info - @memcached_gem_info ||= Rails.cache.read_multi(*@gem_information.values) - end -end diff --git a/app/models/gem_download.rb b/app/models/gem_download.rb index 7b3e3fde5a8..8462392ec01 100644 --- a/app/models/gem_download.rb +++ b/app/models/gem_download.rb @@ -2,9 +2,16 @@ class GemDownload < ApplicationRecord belongs_to :rubygem, optional: true belongs_to :version, optional: true - scope(:most_downloaded_gems, -> { where("version_id != 0").includes(:version).order(count: :desc) }) + scope(:most_downloaded_gems, -> { for_versions.includes(:version).order(count: :desc) }) + scope(:for_versions, -> { where.not(version_id: 0) }) + scope(:for_rubygems, -> { where(version_id: 0) }) + scope(:total, -> { where(version_id: 0, rubygem_id: 0) }) class << self + def for_all_gems + GemDownload.create_with(count: 0).find_or_create_by!(version_id: 0, rubygem_id: 0) + end + def count_for_version(id) v = Version.find(id) return 0 unless v @@ -29,14 +36,12 @@ def most_downloaded_gem_count def increment(count, rubygem_id:, version_id: 0) scope = GemDownload.where(rubygem_id: rubygem_id).select("id") scope = scope.where(version_id: version_id) - sql = scope.to_sql - - update = "UPDATE #{quoted_table_name} SET count = count + ? WHERE id = (#{sql}) RETURNING *" + return scope.first if count.zero? # TODO: Remove this comments, once we move to GemDownload only. # insert = "INSERT INTO #{quoted_table_name} (rubygem_id, version_id, count) SELECT ?, ?, ?" # find_by_sql(["WITH upsert AS (#{update}) #{insert} WHERE NOT EXISTS (SELECT * FROM upsert)", count, rubygem_id, version_id, count]).first - find_by_sql([update, count]).first + scope.update_all(["count = count + ?", count]) end # Takes an array where members have the form @@ -55,20 +60,27 @@ def bulk_update(ary) end return if updates_by_version.empty? - updates_by_version.each_value do |version, version_count| - updates_by_gem[version.rubygem_id] ||= 0 - updates_by_gem[version.rubygem_id] += version_count - end - updates_by_version.values.sort_by { |v, _| v.id }.each do |version, count| - # Gem version count - increment(count, rubygem_id: version.rubygem_id, version_id: version.id) + total_count = 0 + updates_by_version.each_value.each_slice(1_000) do |versions| + rubygem_ids = [] + version_ids = [] + downloads = [] + versions.each do |(version, version_count)| + updates_by_gem[version.rubygem_id] ||= 0 + updates_by_gem[version.rubygem_id] += version_count + + total_count += version_count + + rubygem_ids << version.rubygem_id + version_ids << version.id + downloads << version_count + end + increment_versions(rubygem_ids, version_ids, downloads) end update_gem_downloads(updates_by_gem) - total_count = updates_by_gem.values.sum - # Total count increment(total_count, rubygem_id: 0, version_id: 0) end @@ -76,33 +88,44 @@ def bulk_update(ary) private def count_for(rubygem_id: 0, version_id: 0) - count = GemDownload.where(rubygem_id: rubygem_id, version_id: version_id).pluck(:count).first + count = GemDownload.where(rubygem_id: rubygem_id, version_id: version_id).pick(:count) count || 0 end # updates the downloads field of rubygems in DB and ES index # input: { rubygem_id => download_count_to_increment } def update_gem_downloads(updates_by_gem) - bulk_update_query = [] updates_by_version = most_recent_version_downloads(updates_by_gem.keys) - downloads_by_gem(updates_by_gem.keys).each do |id, downloads| - bulk_update_query << update_query(id, downloads + updates_by_gem[id], updates_by_version[id]) + bulk_update_query = downloads_by_gem(updates_by_gem.keys).map do |id, downloads| + update_query(id, downloads + updates_by_gem[id], updates_by_version[id]) end increment_rubygems(updates_by_gem.keys, updates_by_gem.values) # update ES index of rubygems - Rubygem.__elasticsearch__.client.bulk body: bulk_update_query - rescue Faraday::ConnectionFailed, Elasticsearch::Transport::Transport::Error => e - Rails.logger.debug "ES update: #{updates_by_gem} has failed: #{e.message}" + Searchkick.client.bulk body: bulk_update_query + rescue Faraday::ConnectionFailed, Searchkick::Error, OpenSearch::Transport::Transport::Error => e + logger.debug { { message: "ES update failed", exception: e, updates_by_gem: } } + end + + def increment_versions(rubygem_ids, version_ids, downloads) + query = <<~SQL.squish + count = #{quoted_table_name}.count + updates_by_gem.downloads + FROM + (SELECT UNNEST(ARRAY[?]) AS r_id, UNNEST(ARRAY[?]) AS v_id, UNNEST(ARRAY[?]) AS downloads) AS updates_by_gem + WHERE #{quoted_table_name}.rubygem_id = updates_by_gem.r_id AND #{quoted_table_name}.version_id = updates_by_gem.v_id + SQL + update_all([query, rubygem_ids, version_ids, downloads]) end def increment_rubygems(rubygem_ids, downloads) - query = "UPDATE gem_downloads SET count = gem_downloads.count + updates_by_gem.downloads + query = <<~SQL.squish + count = #{quoted_table_name}.count + updates_by_gem.downloads FROM (SELECT UNNEST(ARRAY[?]) AS r_id, UNNEST(ARRAY[?]) AS downloads) AS updates_by_gem - WHERE gem_downloads.rubygem_id = updates_by_gem.r_id AND gem_downloads.version_id = 0;" - find_by_sql([query, rubygem_ids, downloads]) + WHERE #{quoted_table_name}.rubygem_id = updates_by_gem.r_id AND #{quoted_table_name}.version_id = 0 + SQL + update_all([query, rubygem_ids, downloads]) end def downloads_by_gem(rubygem_ids) @@ -113,7 +136,6 @@ def downloads_by_gem(rubygem_ids) def update_query(id, downloads, version_downloads) { update: { _index: "rubygems-#{Rails.env}", - _type: "rubygem", _id: id, data: { doc: { downloads: downloads, version_downloads: version_downloads } } } } end diff --git a/app/models/gem_info.rb b/app/models/gem_info.rb index b66c24dd35b..629722c02e5 100644 --- a/app/models/gem_info.rb +++ b/app/models/gem_info.rb @@ -1,11 +1,11 @@ class GemInfo - def initialize(rubygem_name) + def initialize(rubygem_name, cached: true) @rubygem_name = rubygem_name + @cached = cached end def compact_index_info - info = Rails.cache.read("info/#{@rubygem_name}") - if info + if @cached && (info = Rails.cache.read("info/#{@rubygem_name}")) StatsD.increment "compact_index.memcached.info.hit" info else @@ -21,13 +21,12 @@ def info_checksum Digest::MD5.hexdigest(compact_index_info) end - def self.ordered_names - names = Rails.cache.read("names") - if names + def self.ordered_names(cached: true) + if cached && (names = Rails.cache.read("names")) StatsD.increment "compact_index.memcached.names.hit" else StatsD.increment "compact_index.memcached.names.miss" - names = Rubygem.order("name").pluck("name") + names = Rubygem.with_versions.order("name").pluck("name") Rails.cache.write("names", names) end names @@ -49,16 +48,17 @@ def self.compact_index_versions(date) map_gem_versions(execute_raw_sql(query).map { |v| [v["name"], [v]] }) end - def self.compact_index_public_versions + def self.compact_index_public_versions(updated_at) query = ["SELECT r.name, v.indexed, COALESCE(v.yanked_at, v.created_at) as stamp, v.sha256, COALESCE(v.yanked_info_checksum, v.info_checksum) as info_checksum, v.number, v.platform FROM rubygems AS r, versions AS v - WHERE v.rubygem_id = r.id - ORDER BY r.name, stamp, v.number, v.platform"] + WHERE v.rubygem_id = r.id AND + (v.created_at <= ? OR v.yanked_at <= ?) + ORDER BY r.name, stamp, v.number, v.platform", updated_at, updated_at] versions_by_gem = execute_raw_sql(query).group_by { |v| v["name"] } - versions_by_gem.each do |_, versions| + versions_by_gem.each_value do |versions| info_checksum = versions.last["info_checksum"] versions.select! { |v| v["indexed"] == true } # Set all versions' info_checksum to work around https://github.com/bundler/compact_index/pull/20 @@ -106,7 +106,8 @@ def compute_compact_index_info end end - CompactIndex::GemVersion.new(r[0], r[1], Version._sha256_hex(r[2]), r[3], deps, r[4], r[5]) + name, platform, checksum, info_checksum, ruby_version, rubygems_version, = r + CompactIndex::GemVersion.new(name, platform, Version._sha256_hex(checksum), info_checksum, deps, ruby_version, rubygems_version) end end diff --git a/app/models/gem_name_reservation.rb b/app/models/gem_name_reservation.rb new file mode 100644 index 00000000000..cb4e685a20a --- /dev/null +++ b/app/models/gem_name_reservation.rb @@ -0,0 +1,15 @@ +class GemNameReservation < ApplicationRecord + validates :name, uniqueness: { case_sensitive: false }, presence: true, length: { maximum: Gemcutter::MAX_FIELD_LENGTH } + validate :downcase_name_check + + def self.reserved?(name) + where(name: name.downcase).any? + end + + private + + def downcase_name_check + return unless name.to_s != name.to_s.downcase + errors.add(:name, "must be all lowercase") + end +end diff --git a/app/models/gem_typo.rb b/app/models/gem_typo.rb index 943273b7add..be64fd0aeca 100644 --- a/app/models/gem_typo.rb +++ b/app/models/gem_typo.rb @@ -1,6 +1,6 @@ class GemTypo DOWNLOADS_THRESHOLD = 10_000 - LAST_RELEASE_TIME = Time.zone.now - 5.years + LAST_RELEASE_TIME = 5.years.ago attr_reader :protected_gem @@ -35,6 +35,6 @@ def matched_protected_gem_name def not_protected?(rubygem) return true unless rubygem - rubygem.downloads < DOWNLOADS_THRESHOLD && rubygem.versions.most_recent.created_at < LAST_RELEASE_TIME + rubygem.downloads < DOWNLOADS_THRESHOLD && rubygem.most_recent_version.created_at < LAST_RELEASE_TIME end end diff --git a/app/models/gem_typo_exception.rb b/app/models/gem_typo_exception.rb index 98db3b92cde..668c5e3830b 100644 --- a/app/models/gem_typo_exception.rb +++ b/app/models/gem_typo_exception.rb @@ -1,5 +1,5 @@ class GemTypoException < ApplicationRecord - validates :name, presence: true, uniqueness: { case_sensitive: false } + validates :name, presence: true, uniqueness: { case_sensitive: false }, length: { maximum: Gemcutter::MAX_FIELD_LENGTH } validate :rubygems_name private diff --git a/app/models/geoip_info.rb b/app/models/geoip_info.rb new file mode 100644 index 00000000000..751095e610c --- /dev/null +++ b/app/models/geoip_info.rb @@ -0,0 +1,19 @@ +class GeoipInfo < ApplicationRecord + has_many :ip_addresses, dependent: :nullify + has_many :user_events, class_name: "Events::UserEvent", dependent: :nullify + has_many :rubygem_events, class_name: "Events::RubygemEvent", dependent: :nullify + + validates :continent_code, :country_code, length: { maximum: 2 } + validates :country_code3, length: { maximum: 3 } + + def to_s + parts = [city&.titleize, region&.upcase, country_code&.upcase].compact + if !parts.empty? + parts.join(", ") + elsif country_name + country_name + else + "Unknown" + end + end +end diff --git a/app/models/ip_address.rb b/app/models/ip_address.rb new file mode 100644 index 00000000000..f2bd1fc4851 --- /dev/null +++ b/app/models/ip_address.rb @@ -0,0 +1,16 @@ +class IpAddress < ApplicationRecord + validates :ip_address, presence: true, uniqueness: true + validates :hashed_ip_address, presence: true, uniqueness: true + + before_validation :hash_ip_address! + + belongs_to :geoip_info, optional: true + has_many :user_events, class_name: "Events::UserEvent", dependent: :nullify + has_many :rubygem_events, class_name: "Events::RubygemEvent", dependent: :nullify + + def hash_ip_address! + self.hashed_ip_address ||= Digest::SHA256.hexdigest(ip_address.to_s) + end + + delegate :to_s, to: :ip_address +end diff --git a/app/models/link_verification.rb b/app/models/link_verification.rb new file mode 100644 index 00000000000..769cf580267 --- /dev/null +++ b/app/models/link_verification.rb @@ -0,0 +1,80 @@ +class LinkVerification < ApplicationRecord + belongs_to :linkable, polymorphic: true + + MAX_FAILURES = 10 + VALIDITY = 1.month + + def self.verified + where(last_verified_at: VALIDITY.ago.beginning_of_day..) + end + + def self.unverified + never_verified + .or(last_verified_before(VALIDITY.ago.beginning_of_day)) + end + + def self.never_verified + where(last_verified_at: nil) + end + + def self.last_verified_before(time) + where(last_verified_at: ...time) + end + + def self.pending_verification + never_verified + .or(last_verified_before(3.weeks.ago.beginning_of_day)) + .where(failures_since_last_verification: 0) + .https_uri + end + + def self.https_uri + where(arel_table[:uri].matches("https://%")) + end + + def self.linkable(linkable) + where(linkable:) + end + + def self.for_uri(uri) + where(uri:) + end + + def unverified? + !verified? + end + + def verified? + return false unless (verified_at = last_verified_at.presence) + + verified_at > VALIDITY.ago + end + + def should_verify? + return false unless https? + return false unless failures_since_last_verification <= 0 + + unverified? || last_verified_at.before?(3.weeks.ago.beginning_of_day) + end + + def verify_later + VerifyLinkJob.perform_later(link_verification: self) + end + + def retry_if_needed + if previously_new_record? && should_verify? + verify_later + return self + end + + return unless https? + return unless failures_since_last_verification.positive? && last_failure_at.present? + return unless last_verified_at.nil? || last_verified_at.before?(last_failure_at) + + update!(failures_since_last_verification: 0) + end + + def https? + uri.start_with?("https://") + end +end diff --git a/app/models/links.rb b/app/models/links.rb index 21f3fce97c2..3d92b093e6a 100644 --- a/app/models/links.rb +++ b/app/models/links.rb @@ -1,4 +1,6 @@ class Links + include Enumerable + # Links available for indexed gems LINKS = { "home" => "homepage_uri", @@ -17,12 +19,13 @@ class Links "docs" => "documentation_uri" }.freeze - attr_accessor :rubygem, :version, :linkset + attr_accessor :rubygem, :version, :linkset, :link_verifications def initialize(rubygem, version) self.rubygem = rubygem self.version = version self.linkset = rubygem.linkset + self.link_verifications = rubygem.link_verifications.verified.for_uri(version ? each.map { |_, url| url } : []).strict_loading end def links @@ -31,26 +34,37 @@ def links delegate :keys, to: :links + def unique_links + links.uniq do |_short, long| + send(long) + end + end + def each return enum_for(:each) unless block_given? - links.each do |short, long| + unique_links.each do |short, long| value = send(long) yield short, value if value end end + def verified?(uri) + # intentionally using #any? here because we want to ensure the query is materialized only once to avoid an N+1 + link_verifications.any? { |lv| lv.uri == uri } + end + # documentation uri: # if metadata has it defined, use that # or if linksets has it defined, use that # else, generate one from gem name and version number def documentation_uri return version.metadata["documentation_uri"].presence if version.metadata_uri_set? - linkset&.docs&.presence || "https://www.rubydoc.info/gems/#{rubygem.name}/#{version.number}" + linkset&.docs.presence || "https://www.rubydoc.info/gems/#{rubygem.name}/#{version.number}" end # technically this is a path def download_uri - "/downloads/#{version.full_name}.gem" if version.indexed + "/downloads/#{version.gem_file_name}" if version.indexed end # excluded from metadata_uri_set? check diff --git a/app/models/linkset.rb b/app/models/linkset.rb index 344b77e30d4..ccea8016728 100644 --- a/app/models/linkset.rb +++ b/app/models/linkset.rb @@ -1,6 +1,8 @@ class Linkset < ApplicationRecord belongs_to :rubygem + before_save :create_homepage_link_verification, if: :home_changed? + LINKS = %w[home code docs wiki mail bugs].freeze LINKS.each do |url| @@ -9,6 +11,8 @@ class Linkset < ApplicationRecord allow_nil: true, allow_blank: true, message: "does not appear to be a valid URL" + + validates url, length: { maximum: Gemcutter::MAX_FIELD_LENGTH } end def empty? @@ -18,4 +22,9 @@ def empty? def update_attributes_from_gem_specification!(spec) update!(home: spec.homepage) end + + def create_homepage_link_verification + return if home.blank? + rubygem.link_verifications.find_or_create_by!(uri: home).retry_if_needed + end end diff --git a/app/models/log_ticket.rb b/app/models/log_ticket.rb index 380d76947a4..51b3ac47ba9 100644 --- a/app/models/log_ticket.rb +++ b/app/models/log_ticket.rb @@ -1,15 +1,16 @@ class LogTicket < ApplicationRecord - enum backend: { s3: 0, local: 1 } - - scope(:pending, -> { limit(1).lock(true).select("id").where(status: "pending").order("id ASC") }) + enum :backend, { s3: 0, local: 1 } + enum :status, %i[pending processing failed processed].index_with(&:to_s) def self.pop(key: nil, directory: nil) - scope = pending + scope = pending.limit(1).lock(true).order("id ASC") scope = scope.where(key: key) if key scope = scope.where(directory: directory) if directory - sql = scope.to_sql - - find_by_sql(["UPDATE #{quoted_table_name} SET status = ? WHERE id IN (#{sql}) RETURNING *", "processing"]).first + scope.sole.tap do |ticket| + ticket.update_column(:status, "processing") + end + rescue ActiveRecord::RecordNotFound + nil # no ticket in queue found by `sole` call end def fs diff --git a/app/models/membership.rb b/app/models/membership.rb new file mode 100644 index 00000000000..e1e73cf2a9c --- /dev/null +++ b/app/models/membership.rb @@ -0,0 +1,13 @@ +class Membership < ApplicationRecord + belongs_to :user + belongs_to :organization + + scope :unconfirmed, -> { where(confirmed_at: nil) } + scope :confirmed, -> { where.not(confirmed_at: nil) } + + enum :role, { owner: Access::OWNER, maintainer: Access::MAINTAINER, admin: Access::ADMIN }, validate: true, default: :maintainer + + def confirmed? + !confirmed_at.nil? + end +end diff --git a/app/models/oidc.rb b/app/models/oidc.rb new file mode 100644 index 00000000000..80e42efdbca --- /dev/null +++ b/app/models/oidc.rb @@ -0,0 +1,5 @@ +module OIDC + def self.table_name_prefix + "oidc_" + end +end diff --git a/app/models/oidc/access_policy.rb b/app/models/oidc/access_policy.rb new file mode 100644 index 00000000000..7eaf105b5ef --- /dev/null +++ b/app/models/oidc/access_policy.rb @@ -0,0 +1,96 @@ +class OIDC::AccessPolicy < ApplicationModel + class Statement < ApplicationModel + def match_jwt?(jwt) + return false unless principal.oidc == jwt[:iss] + + conditions.all? { _1.match?(jwt) } + end + + class Principal < ApplicationModel + attribute :oidc, :string + + validates :oidc, presence: true + end + + class Condition < ApplicationModel + def match?(jwt) + claim_value = jwt[claim] + case operator + when "string_equals" + value == claim_value + when "string_matches" + Regexp.new(value).match?(claim_value) + else + raise "Unknown operator #{operator.inspect}" + end + end + + attribute :operator, :string + attribute :claim, :string + attribute :value + + STRING_BOOLEAN_OPERATORS = %w[string_equals string_matches].freeze + + OPERATORS = STRING_BOOLEAN_OPERATORS + + validates :operator, presence: true, inclusion: { in: OPERATORS } + validates :claim, presence: true + validate :value_expected_type? + + def value_type + case operator + when *STRING_BOOLEAN_OPERATORS + String + else + NilClass + end + end + + def value_expected_type? + errors.add(:value, "must be #{value_type}") unless value.is_a?(value_type) + end + end + + EFFECTS = %w[allow deny].freeze + + attribute :effect, :string + attribute :principal, Types::JsonDeserializable.new(Principal) + attribute :conditions, Types::ArrayOf.new(Types::JsonDeserializable.new(Condition)) + + validates :effect, presence: true, inclusion: { in: EFFECTS } + + validates :principal, presence: true, nested: true + + validates :conditions, nested: true, presence: true + + def conditions_attributes=(attributes) + self.conditions = attributes.map { Condition.new(_2) } + end + end + + attribute :statements, Types::ArrayOf.new(Types::JsonDeserializable.new(Statement)) + + validates :statements, presence: true, nested: true + + def statements_attributes=(attributes) + self.statements = attributes.map { Statement.new(_2) } + end + + class AccessError < StandardError + end + + def verify_access!(jwt) + matching_statements = statements.select { _1.match_jwt?(jwt) } + raise AccessError, "denying due to no matching statements" if matching_statements.empty? + + case (effect = matching_statements.last.effect) + when "allow" + # great, nothing to do. verified + nil + when "deny" + raise AccessError, "explicit denial from #{matching_statements.last.as_json}" + else + raise "Unhandled effect #{effect}" + end + end +end diff --git a/app/models/oidc/api_key_permissions.rb b/app/models/oidc/api_key_permissions.rb new file mode 100644 index 00000000000..9de5d9524c3 --- /dev/null +++ b/app/models/oidc/api_key_permissions.rb @@ -0,0 +1,41 @@ +class OIDC::ApiKeyPermissions < ApplicationModel + def create_params(user) + params = { scopes: scopes } + params[:ownership] = gems&.first&.then { user.ownerships.joins(:rubygem).find_by!(rubygem: { name: _1 }) } + params[:expires_at] = DateTime.now.utc + valid_for + params + end + + attribute :scopes, Types::ArrayOf.new(:string) + attribute :valid_for, :duration, default: -> { 30.minutes.freeze } + attribute :gems, Types::ArrayOf.new(:string) + + validates :scopes, presence: true + validate :known_scopes? + validate :scopes_must_be_unique + + validates :valid_for, presence: true, inclusion: { in: (5.minutes)..(1.day) } + + validates :gems, length: { maximum: 1 } + + def gems=(gems) + if gems == [""] # all gems, from form + super(nil) + else + super + end + end + + def known_scopes? + scopes&.each_with_index do |scope, idx| + errors.add("scopes[#{idx}]", "unknown scope: #{scope}") unless ApiKey::API_SCOPES.include?(scope.to_sym) + end + end + + def scopes_must_be_unique + return if scopes.blank? + + errors.add(:scopes, "show_dashboard is exclusive") if scopes.include?("show_dashboard") && scopes.size > 1 + errors.add(:scopes, "must be unique") if scopes.dup.uniq! + end +end diff --git a/app/models/oidc/api_key_role.rb b/app/models/oidc/api_key_role.rb new file mode 100644 index 00000000000..9111cc723e6 --- /dev/null +++ b/app/models/oidc/api_key_role.rb @@ -0,0 +1,90 @@ +class OIDC::ApiKeyRole < ApplicationRecord + belongs_to :provider, class_name: "OIDC::Provider", foreign_key: "oidc_provider_id", inverse_of: :api_key_roles + belongs_to :user, inverse_of: :oidc_api_key_roles + + has_many :id_tokens, -> { order(created_at: :desc) }, + class_name: "OIDC::IdToken", inverse_of: :api_key_role, foreign_key: :oidc_api_key_role_id, dependent: :restrict_with_exception + has_many :api_keys, through: :id_tokens, inverse_of: :oidc_api_key_role + + scope :for_rubygem, lambda { |rubygem| + if rubygem.blank? + where("(jsonb_typeof((#{arel_table.name}.api_key_permissions->'gems')::jsonb) = 'null' OR " \ + "jsonb_array_length((#{arel_table.name}.api_key_permissions->'gems')::jsonb) = 0)") + else + where("(#{arel_table.name}.api_key_permissions->'gems')::jsonb @> ?", %([#{rubygem.name.to_json}])) + end + } + + scope :for_scope, lambda { |scope| + where("(#{arel_table.name}.api_key_permissions->'scopes')::jsonb @> ?", %([#{scope.to_json}])) + } + + scope :deleted, -> { where.not(deleted_at: nil) } + scope :active, -> { where(deleted_at: nil) } + + validates :name, presence: true, length: { maximum: Gemcutter::MAX_FIELD_LENGTH }, uniqueness: { scope: :user_id } + + attribute :api_key_permissions, Types::JsonDeserializable.new(OIDC::ApiKeyPermissions) + validates :api_key_permissions, presence: true, nested: true + validate :gems_belong_to_user + + def github_actions_push? + provider.github_actions? && api_key_permissions.scopes.include?("push_rubygem") + end + + def gems_belong_to_user + Array.wrap(api_key_permissions&.gems).each_with_index do |name, idx| + errors.add("api_key_permissions.gems[#{idx}]", "(#{name}) does not belong to user #{user.display_handle}") if user.rubygems.where(name:).empty? + end + end + + before_validation :set_statement_principals + attribute :access_policy, Types::JsonDeserializable.new(OIDC::AccessPolicy) + validates :access_policy, presence: true, nested: true + validate :all_condition_claims_are_known + + # Since the only current value of this is the provider's issuer, we can set it automatically. + def set_statement_principals + return unless provider + access_policy&.statements&.each do |statement| + statement.principal ||= OIDC::AccessPolicy::Statement::Principal.new + next if statement.principal.oidc.present? + statement.principal.oidc = provider.issuer + end + end + + def all_condition_claims_are_known + return unless provider + known_claims = provider.configuration.claims_supported + access_policy.statements&.each_with_index do |s, si| + s.conditions&.each_with_index do |c, ci| + next if known_claims&.include?(c.claim) + errors.add("access_policy.statements[#{si}].conditions[#{ci}].claim", + "unknown for the provider") + c.errors.add(:claim, + "unknown for the provider") + end + end + end + + # https://www.crockford.com/base32.html + CROCKFORD_BASE_32_ALPHABET = ("0".."9").to_a + ("a".."z").to_a - %w[0 i l u] + validates :token, presence: true, uniqueness: true, length: { minimum: 32, maximum: 32 }, + format: { with: /\Arg_oidc_akr_[#{CROCKFORD_BASE_32_ALPHABET}]+\z/o } + + before_validation :generate_random_token, if: :new_record? + def generate_random_token + 5.times do + suffix = SecureRandom.random_bytes(20).unpack("C*").map do |byte| + idx = byte % 32 + CROCKFORD_BASE_32_ALPHABET[idx] + end.join + + self.token = "rg_oidc_akr_#{suffix}" + + return if self.class.where(token:).empty? + end + + raise "could not generate unique token" + end +end diff --git a/app/models/oidc/id_token.rb b/app/models/oidc/id_token.rb new file mode 100644 index 00000000000..60e00828edc --- /dev/null +++ b/app/models/oidc/id_token.rb @@ -0,0 +1,45 @@ +class OIDC::IdToken < ApplicationRecord + belongs_to :api_key_role, class_name: "OIDC::ApiKeyRole", foreign_key: :oidc_api_key_role_id, inverse_of: :id_tokens + belongs_to :api_key, inverse_of: :oidc_id_token + has_one :provider, through: :api_key_role, inverse_of: :id_tokens + has_one :user, through: :api_key_role, inverse_of: :oidc_id_tokens + + validates :jwt, presence: true + validate :jti_uniqueness + + def self.provider_id(oidc_provider_id) + joins(:api_key_role).where(api_key_role: { oidc_provider_id: }) + end + + def payload + { + api_key_role_token: api_key_role.token, + jwt: jwt.slice("claims", "header") + } + end + + delegate :as_json, :to_yaml, to: :payload + + def to_xml(options = {}) + payload.to_xml(options.merge(root: "oidc:id_token")) + end + + def jti + jwt&.dig("claims", "jti") + end + + def claims + jwt&.dig("claims") + end + + def header + jwt&.dig("header") + end + + def jti_uniqueness + relation = self.class.where("(jwt->>'claims')::jsonb->>'jti' = ?", jti) + relation = relation.provider_id(api_key_role.oidc_provider_id) if api_key_role + return unless relation.where.not(id: self).exists? + errors.add("jwt.claims.jti", "must be unique") + end +end diff --git a/app/models/oidc/pending_trusted_publisher.rb b/app/models/oidc/pending_trusted_publisher.rb new file mode 100644 index 00000000000..024a11a8c85 --- /dev/null +++ b/app/models/oidc/pending_trusted_publisher.rb @@ -0,0 +1,38 @@ +class OIDC::PendingTrustedPublisher < ApplicationRecord + belongs_to :user + belongs_to :trusted_publisher, polymorphic: true, optional: false + + accepts_nested_attributes_for :trusted_publisher + + validates :rubygem_name, + length: { maximum: Gemcutter::MAX_FIELD_LENGTH }, + presence: true, + name_format: true, + uniqueness: { case_sensitive: false, scope: %i[trusted_publisher_id trusted_publisher_type], conditions: -> { unexpired } } + + validate :available_rubygem_name, on: :create + + scope :unexpired, -> { where(arel_table[:expires_at].eq(nil).or(arel_table[:expires_at].gt(Time.now.utc))) } + scope :expired, -> { where(arel_table[:expires_at].lteq(Time.now.utc)) } + + scope :rubygem_name_is, lambda { |name| + sensitive = where(rubygem_name: name.strip).limit(1) + return sensitive unless sensitive.empty? + + where("UPPER(rubygem_name) = UPPER(?)", name.strip).limit(1) + } + + def build_trusted_publisher(params) + self.trusted_publisher = trusted_publisher_type.constantize.build_trusted_publisher(params) + end + + private + + def available_rubygem_name + return if rubygem_name.blank? + rubygem = Rubygem.name_is(rubygem_name).first + return if rubygem.nil? || rubygem.pushable? + + errors.add(:rubygem_name, :unavailable) + end +end diff --git a/app/models/oidc/provider.rb b/app/models/oidc/provider.rb new file mode 100644 index 00000000000..04361fff603 --- /dev/null +++ b/app/models/oidc/provider.rb @@ -0,0 +1,55 @@ +class OIDC::Provider < ApplicationRecord + validate :issuer_match, if: :configuration + before_validation -> { configuration&.expected_issuer = issuer } + + validates :configuration, nested: true + validates :issuer, uniqueness: { ignore_case: true } + + has_many :api_key_roles, class_name: "OIDC::ApiKeyRole", inverse_of: :provider, foreign_key: :oidc_provider_id, dependent: :restrict_with_exception + has_many :users, through: :api_key_roles, inverse_of: :oidc_providers + has_many :id_tokens, through: :api_key_roles, inverse_of: :provider + + has_many :audits, as: :auditable, dependent: :nullify + + GITHUB_ACTIONS_ISSUER = "https://token.actions.githubusercontent.com".freeze + + def self.github_actions + find_by(issuer: GITHUB_ACTIONS_ISSUER) + end + + def github_actions? + issuer == GITHUB_ACTIONS_ISSUER + end + + class Configuration < ::OpenIDConnect::Discovery::Provider::Config::Response + attr_optional required_attributes.delete(:authorization_endpoint) + + def initialize(hash) + super(hash.deep_symbolize_keys) + end + + def valid? + super + errors.delete(:authorization_endpoint, :blank) + errors.none? + end + end + + attribute :configuration, Types::JsonDeserializable.new(Configuration) + + attribute :jwks, Types::JsonDeserializable.new(JSON::JWK::Set) + + def trusted_publisher_class + case issuer + when GITHUB_ACTIONS_ISSUER + OIDC::TrustedPublisher::GitHubAction + end + end + + private + + def issuer_match + return if issuer == configuration.issuer + errors.add :configuration, "issuer (#{configuration.issuer}) does not match the provider issuer: #{issuer}" + end +end diff --git a/app/models/oidc/rubygem_trusted_publisher.rb b/app/models/oidc/rubygem_trusted_publisher.rb new file mode 100644 index 00000000000..17ad5c781e1 --- /dev/null +++ b/app/models/oidc/rubygem_trusted_publisher.rb @@ -0,0 +1,22 @@ +class OIDC::RubygemTrustedPublisher < ApplicationRecord + belongs_to :rubygem + belongs_to :trusted_publisher, polymorphic: true, optional: false + + accepts_nested_attributes_for :trusted_publisher + + validates :rubygem, uniqueness: { scope: %i[trusted_publisher_id trusted_publisher_type] } + + def build_trusted_publisher(params) + self.trusted_publisher = trusted_publisher_type.constantize.build_trusted_publisher(params) + end + + def payload + { + id:, + trusted_publisher_type:, + trusted_publisher: trusted_publisher + } + end + + delegate :as_json, to: :payload +end diff --git a/app/models/oidc/trusted_publisher.rb b/app/models/oidc/trusted_publisher.rb new file mode 100644 index 00000000000..ad9a68da98a --- /dev/null +++ b/app/models/oidc/trusted_publisher.rb @@ -0,0 +1,9 @@ +module OIDC::TrustedPublisher + def self.table_name_prefix + "oidc_trusted_publisher_" + end + + def self.all + [GitHubAction] + end +end diff --git a/app/models/oidc/trusted_publisher/github_action.rb b/app/models/oidc/trusted_publisher/github_action.rb new file mode 100644 index 00000000000..11f02a61ff1 --- /dev/null +++ b/app/models/oidc/trusted_publisher/github_action.rb @@ -0,0 +1,174 @@ +class OIDC::TrustedPublisher::GitHubAction < ApplicationRecord + has_many :rubygem_trusted_publishers, class_name: "OIDC::RubygemTrustedPublisher", as: :trusted_publisher, dependent: :destroy, + inverse_of: :trusted_publisher + has_many :pending_trusted_publishers, class_name: "OIDC::PendingTrustedPublisher", as: :trusted_publisher, dependent: :destroy, + inverse_of: :trusted_publisher + has_many :rubygems, through: :rubygem_trusted_publishers + has_many :api_keys, dependent: :destroy, inverse_of: :owner, as: :owner + + before_validation :find_github_repository_owner_id + + validates :repository_owner, :repository_name, :workflow_filename, :repository_owner_id, + presence: true, length: { maximum: Gemcutter::MAX_FIELD_LENGTH } + validates :environment, presence: true, allow_nil: true, length: { maximum: Gemcutter::MAX_FIELD_LENGTH } + + validate :unique_publisher + validate :workflow_filename_format + + def self.for_claims(claims) + repository = claims.fetch(:repository) + repository_owner, repository_name = repository.split("/", 2) + workflow_prefix = "#{repository}/.github/workflows/" + workflow_ref = claims.fetch(:job_workflow_ref).delete_prefix(workflow_prefix) + workflow_filename = workflow_ref.sub(/@[^@]+\z/, "") + + required = { + repository_owner:, repository_name:, workflow_filename:, + repository_owner_id: claims.fetch(:repository_owner_id) + } + + base = where(required) + if (env = claims[:environment]) + base.where(environment: env).or(base.where(environment: nil)).order(environment: :asc) # NULLS LAST by default for asc + else + base.where(environment: nil) + end.first! + end + + def self.permitted_attributes + %i[repository_owner repository_name workflow_filename environment] + end + + def self.build_trusted_publisher(params) + params = params.reverse_merge(repository_owner_id: nil, repository_name: nil, workflow_filename: nil, environment: nil) + params.delete(:environment) if params[:environment].blank? + params.delete(:repository_owner_id) + find_or_initialize_by(params) + end + + def self.publisher_name = "GitHub Actions" + + def payload + { + name:, + repository_owner:, + repository_name:, + repository_owner_id:, + workflow_filename:, + environment: + } + end + + delegate :as_json, to: :payload + + def repository_condition + OIDC::AccessPolicy::Statement::Condition.new( + operator: "string_equals", + claim: "repository", + value: [repository_owner, repository_name].join("/") + ) + end + + def environment_condition + return if environment.blank? + OIDC::AccessPolicy::Statement::Condition.new( + operator: "string_equals", + claim: "environment", + value: environment + ) + end + + def repository_owner_id_condition + OIDC::AccessPolicy::Statement::Condition.new( + operator: "string_equals", + claim: "repository_owner_id", + value: repository_owner_id + ) + end + + def audience_condition + OIDC::AccessPolicy::Statement::Condition.new( + operator: "string_equals", + claim: "aud", + value: Gemcutter::HOST + ) + end + + def job_workflow_ref_condition(ref) + OIDC::AccessPolicy::Statement::Condition.new( + operator: "string_equals", + claim: "job_workflow_ref", + value: "#{repository}/#{workflow_slug}@#{ref}" + ) + end + + def to_access_policy(jwt) + common_conditions = [repository_condition, environment_condition, repository_owner_id_condition, audience_condition].compact + refs = [jwt.fetch(:ref), jwt.fetch(:sha)].compact_blank + raise OIDC::AccessPolicy::AccessError, "ref and sha are both missing" if refs.empty? + OIDC::AccessPolicy.new( + statements: refs.map do |ref| + OIDC::AccessPolicy::Statement.new( + effect: "allow", + principal: OIDC::AccessPolicy::Statement::Principal.new( + oidc: OIDC::Provider::GITHUB_ACTIONS_ISSUER + ), + conditions: common_conditions + [job_workflow_ref_condition(ref)] + ) + end + ) + end + + def name + name = "#{self.class.publisher_name} #{repository_owner}/#{repository_name} @ #{workflow_slug}" + name << " (#{environment})" if environment? + name + end + + def repository = "#{repository_owner}/#{repository_name}" + + def workflow_slug = ".github/workflows/#{workflow_filename}" + + def owns_gem?(rubygem) = rubygem_trusted_publishers.exists?(rubygem: rubygem) + + def ld_context + LaunchDarkly::LDContext.create( + key: "#{model_name.singular}-key-#{id}", + kind: "trusted_publisher", + name: name + ) + end + + private + + def find_github_repository_owner_id + return if repository_owner.blank? + return if repository_owner_id.present? + + self.repository_owner_id = + begin + Octokit::Client.new.user(repository_owner).id + rescue Octokit::NotFound + nil + end + end + + def unique_publisher + return unless self.class.exists?( + repository_owner: repository_owner, + repository_name: repository_name, + repository_owner_id: repository_owner_id, + workflow_filename: workflow_filename, + environment: environment + ) + + errors.add(:base, "publisher already exists") + end + + def workflow_filename_format + return if workflow_filename.blank? + + errors.add(:workflow_filename, "must end with .yml or .yaml") unless /\.ya?ml\z/.match?(workflow_filename) + errors.add(:workflow_filename, "must be a filename only, without directories") if workflow_filename.include?("/") + end +end diff --git a/app/models/organization.rb b/app/models/organization.rb new file mode 100644 index 00000000000..9194f21afa6 --- /dev/null +++ b/app/models/organization.rb @@ -0,0 +1,25 @@ +class Organization < ApplicationRecord + include Events::Recordable + + validates :handle, presence: true, + uniqueness: { case_sensitive: false }, + length: { within: 2..40 }, + format: { with: Patterns::HANDLE_PATTERN } + validates :name, presence: true, length: { within: 2..255 } + validate :unique_with_user_handle + + def unique_with_user_handle + errors.add(:handle, "has already been taken") if handle && User.where("lower(handle) = lower(?)", handle).any? + end + + has_many :memberships, -> { where.not(confirmed_at: nil) }, dependent: :destroy, inverse_of: :organization + has_many :unconfirmed_memberships, -> { where(confirmed_at: nil) }, class_name: "Membership", dependent: :destroy, inverse_of: :organization + has_many :users, through: :memberships + + scope :not_deleted, -> { where(deleted_at: nil) } + scope :deleted, -> { where.not(deleted_at: nil) } + + after_create do + record_event!(Events::OrganizationEvent::CREATED, actor_gid: memberships.first&.to_gid) + end +end diff --git a/app/models/ownership.rb b/app/models/ownership.rb index f571ba8710a..bbe1f3776c8 100644 --- a/app/models/ownership.rb +++ b/app/models/ownership.rb @@ -2,16 +2,27 @@ class Ownership < ApplicationRecord belongs_to :rubygem belongs_to :user belongs_to :authorizer, class_name: "User" + has_many :api_key_rubygem_scopes, dependent: :destroy - validates :user_id, uniqueness: { scope: :rubygem_id } + validate :validate_unique_user delegate :name, to: :user, prefix: :owner delegate :name, to: :authorizer, prefix: true, allow_nil: true before_create :generate_confirmation_token - scope :confirmed, -> { where("confirmed_at IS NOT NULL") } - scope :unconfirmed, -> { where("confirmed_at IS NULL") } + after_create :record_create_event + after_update :record_confirmation_event, if: :saved_change_to_confirmed_at? + after_update :record_role_updated_event, if: :saved_change_to_role? + after_update :notify_user_role_of_role_change, if: :saved_change_to_role? + after_destroy :record_destroy_event + + scope :confirmed, -> { where.not(confirmed_at: nil) } + scope :unconfirmed, -> { where(confirmed_at: nil) } + + enum :role, { owner: Access::OWNER, maintainer: Access::MAINTAINER }, validate: true, default: :owner + + scope :user_with_minimum_role, ->(user, role) { where(user: user, role: Access.with_minimum_role(role)) } def self.by_indexed_gem_name select("ownerships.*, rubygems.name") @@ -21,13 +32,29 @@ def self.by_indexed_gem_name .order("rubygems.name ASC") end - def self.find_by_owner_handle!(handle) - joins(:user).find_by(users: { handle: handle }) || joins(:user).find_by!(users: { id: handle }) + def self.find_by_owner_handle(handle) + joins(:user).find_by(users: { handle: handle }) || joins(:user).find_by(users: { id: handle }) + end + + def self.create_confirmed(rubygem, user, approver) + rubygem.ownerships.create!(user: user, authorizer: approver).tap(&:confirm!) + end + + def self.update_notifier(to_enable, to_disable, notifer_attr) + where(id: to_enable).update_all(notifer_attr => true) if to_enable.any? + where(id: to_disable).update_all(notifer_attr => false) if to_disable.any? end - def self.create_confirmed(rubygem, user) - ownership = rubygem.ownerships.create(user: user, authorizer: user) - ownership.confirm! + def self.update_push_notifier(to_enable_push, to_disable_push) + update_notifier(to_enable_push, to_disable_push, "push_notifier") + end + + def self.update_owner_notifier(to_enable_owner, to_disable_owner) + update_notifier(to_enable_owner, to_disable_owner, "owner_notifier") + end + + def self.update_ownership_request_notifier(to_enable_ownership_request, to_disable_ownership_request) + update_notifier(to_enable_ownership_request, to_disable_ownership_request, "ownership_request_notifier") end def valid_confirmation_token? @@ -40,7 +67,7 @@ def generate_confirmation_token end def confirm! - update(confirmed_at: Time.current, token: nil) if unconfirmed? + update!(confirmed_at: Time.current, token: nil) if unconfirmed? end def confirmed? @@ -54,4 +81,57 @@ def unconfirmed? def safe_destroy destroy if unconfirmed? || rubygem.owners.many? end + + def validate_unique_user + return unless rubygem && user + ownerships = persisted? ? Ownership.where.not(id: id) : Ownership + other = ownerships.find_by(rubygem:, user:) + return unless other + + if other.confirmed? + errors.add :user_id, I18n.t("activerecord.errors.models.ownership.attributes.user_id.already_confirmed") + else + errors.add :user_id, I18n.t("activerecord.errors.models.ownership.attributes.user_id.already_invited") + end + end + + private + + def record_create_event + rubygem.record_event!(Events::RubygemEvent::OWNER_ADDED, + owner: user.display_handle, + authorizer: authorizer.display_handle, + owner_gid: user.to_gid, + actor_gid: Current.user&.to_gid) + end + + def record_confirmation_event + rubygem.record_event!(Events::RubygemEvent::OWNER_CONFIRMED, + owner: user.display_handle, + authorizer: authorizer.display_handle, + owner_gid: user.to_gid, + actor_gid: Current.user&.to_gid) + end + + def record_role_updated_event + rubygem.record_event!(Events::RubygemEvent::OWNER_ROLE_UPDATED, + owner: user.display_handle, + updated_by: Current.user&.display_handle, + owner_gid: user.to_gid, + actor_gid: Current.user&.to_gid, + previous_role: role_previously_was, + current_role: role) + end + + def record_destroy_event + rubygem.record_event!(Events::RubygemEvent::OWNER_REMOVED, + owner: user.display_handle, + removed_by: Current.user&.display_handle, + owner_gid: user.to_gid, + actor_gid: Current.user&.to_gid) + end + + def notify_user_role_of_role_change + OwnersMailer.with(ownership: self).owner_updated.deliver_later + end end diff --git a/app/models/ownership_call.rb b/app/models/ownership_call.rb new file mode 100644 index 00000000000..01b836dbf77 --- /dev/null +++ b/app/models/ownership_call.rb @@ -0,0 +1,19 @@ +class OwnershipCall < ApplicationRecord + belongs_to :rubygem + belongs_to :user + has_many :ownership_requests, -> { opened }, dependent: :destroy, inverse_of: :ownership_call + + validates :note, length: { maximum: Gemcutter::MAX_TEXT_FIELD_LENGTH } + validates :rubygem_id, :user_id, :status, :note, presence: true + validates :rubygem_id, uniqueness: { conditions: -> { opened }, message: "can have only one open ownership call" } + + delegate :name, to: :rubygem, prefix: true + delegate :display_handle, to: :user, prefix: true + + enum :status, { opened: true, closed: false } + + def close! + ownership_requests.each(&:close!) + update!(status: :closed) + end +end diff --git a/app/models/ownership_request.rb b/app/models/ownership_request.rb new file mode 100644 index 00000000000..3aafdd45514 --- /dev/null +++ b/app/models/ownership_request.rb @@ -0,0 +1,43 @@ +class OwnershipRequest < ApplicationRecord + belongs_to :rubygem + belongs_to :user + belongs_to :ownership_call, optional: true + belongs_to :approver, class_name: "User", optional: true + + validates :status, :note, presence: true + validates :note, length: { maximum: Gemcutter::MAX_TEXT_FIELD_LENGTH } + validates :user_id, uniqueness: { scope: :rubygem_id, conditions: -> { opened } } + validate :not_already_owner, on: :create + + delegate :name, to: :user, prefix: true + delegate :name, to: :rubygem, prefix: true + + enum :status, { opened: 0, approved: 1, closed: 2 } + + def approve!(approver) + return unless Pundit.policy!(approver, self).approve? + transaction do + update!(status: :approved, approver: approver) + Ownership.create_confirmed(rubygem, user, approver) + end + + rubygem.ownership_notifiable_owners.each do |notified_user| + OwnersMailer.owner_added(notified_user.id, user_id, approver.id, rubygem_id).deliver_later + end + + OwnersMailer.ownership_request_approved(id).deliver_later + end + + def close!(closer = nil) + update!(status: :closed) + return if closer && closer == user # Don't notify the requester if they closed their own request + OwnersMailer.ownership_request_closed(id).deliver_later + end + + private + + def not_already_owner + return unless rubygem.owned_by?(user) + errors.add(:user_id, I18n.t("activerecord.errors.models.ownership_request.attributes.user_id.existing")) + end +end diff --git a/app/models/push_policy.rb b/app/models/push_policy.rb new file mode 100644 index 00000000000..b6e671cd832 --- /dev/null +++ b/app/models/push_policy.rb @@ -0,0 +1,9 @@ +PushPolicy = Gem::Security::Policy.new( + "Push Policy", + verify_data: true, + verify_signer: true, + verify_chain: true, + verify_root: true, + only_trusted: false, + only_signed: false +) diff --git a/app/models/pusher.rb b/app/models/pusher.rb index cf110720f95..3da8c835c35 100644 --- a/app/models/pusher.rb +++ b/app/models/pusher.rb @@ -1,40 +1,70 @@ require "digest/sha2" class Pusher - attr_reader :user, :spec, :message, :code, :rubygem, :body, :version, :version_id, :size + include TraceTagger + include SemanticLogger::Loggable + + attr_reader :api_key, :owner, :spec, :spec_contents, :message, :code, :rubygem, :body, :version, :version_id, :size + + def initialize(api_key, body, request: nil) + @api_key = api_key + @owner = api_key.owner + @scoped_rubygem = api_key.rubygem - def initialize(user, body, remote_ip = "") - @user = user @body = StringIO.new(body.read) @size = @body.size - @indexer = Indexer.new - @remote_ip = remote_ip + @request = request end def process - pull_spec && find && authorize && validate && save + trace("gemcutter.pusher.process", tags: { "gemcutter.api_key.owner" => owner.to_gid }) do + pull_spec && find && authorize && verify_gem_scope && verify_mfa_requirement && validate && save + end end def authorize - rubygem.pushable? || rubygem.owned_by?(user) || notify_unauthorized + (rubygem.pushable? && (api_key.user? || find_pending_trusted_publisher)) || owner.owns_gem?(rubygem) || notify_unauthorized + end + + def verify_gem_scope + return true unless @scoped_rubygem && rubygem != @scoped_rubygem + + notify("This API key cannot perform the specified action on this gem.", 403) + end + + def verify_mfa_requirement + (!api_key.user? || owner.mfa_enabled?) || !(version_mfa_required? || rubygem.metadata_mfa_required?) || + notify("Rubygem requires owners to enable MFA. You must enable MFA before pushing new version.", 403) end def validate - (rubygem.valid? && version.valid?) || notify("There was a problem saving your gem: #{rubygem.all_errors(version)}", 403) + unless validate_signature_exists? + return notify("There was a problem saving your gem: \nYou have added cert_chain in gemspec but signature was empty", 403) + end + + return notify("There was a problem saving your gem: #{rubygem.all_errors(version)}", 403) unless rubygem.valid? && version.valid? + + unless version.full_name == spec.original_name && version.gem_full_name == spec.full_name + return notify("There was a problem saving your gem: the uploaded spec has malformed platform attributes", 409) + end + + true end def save # Restructured so that if we fail to write the gem (ie, s3 is down) # can clean things up well. return notify("There was a problem saving your gem: #{rubygem.all_errors(version)}", 403) unless update - @indexer.write_gem @body, @spec + trace("gemcutter.pusher.write_gem") do + write_gem @body, @spec_contents + end rescue ArgumentError => e - @version.destroy - Honeybadger.notify(e) + @version&.destroy + Rails.error.report(e, handled: true) notify("There was a problem saving your gem. #{e}", 400) rescue StandardError => e - @version.destroy - Honeybadger.notify(e) + @version&.destroy + Rails.error.report(e, handled: true) notify("There was a problem saving your gem. Please try again.", 500) else after_write @@ -43,25 +73,64 @@ def save end def pull_spec - @spec = Gem::Package.new(body).spec - rescue StandardError => e - notify <<-MSG.strip_heredoc, 422 + # ensure the body can't be treated as a file path + package_source = Gem::Package::IOSource.new(body) + package = Gem::Package.new(package_source, gem_security_policy) + @spec = package.spec + @files = package.files + validate_spec && serialize_spec + rescue Psych::AliasesNotEnabled + notify <<~MSG, 422 + RubyGems.org cannot process this gem. + Pushing gems where there are aliases in the YAML gemspec is no longer supported. + Ensure you are using a recent version of RubyGems to build the gem by running + `gem update --system` and then try pushing again. + MSG + rescue Gem::Exception, Psych::DisallowedClass, ArgumentError => e + notify <<~MSG, 422 RubyGems.org cannot process this gem. Please try rebuilding it and installing it locally to make sure it's valid. Error: #{e.message} MSG + rescue StandardError + # Ensure arbitrary exceptions are not leaked to the client + notify <<~MSG, 422 + RubyGems.org cannot process this gem. + Please try rebuilding it and installing it locally to make sure it's valid. + MSG end - def find + def find # rubocop:disable Metrics/AbcSize, Metrics/MethodLength name = spec.name.to_s + set_tag "gemcutter.rubygem.name", name @rubygem = Rubygem.name_is(name).first || Rubygem.new(name: name) + sha256 = Digest::SHA2.base64digest(body.string) + spec_sha256 = Digest::SHA2.base64digest(spec_contents) + + version = @rubygem.versions + .create_with(indexed: false, cert_chain: spec.cert_chain) + .find_or_initialize_by( + number: spec.version.to_s, + platform: spec.original_platform.to_s, + gem_platform: spec.platform.to_s, + size: size, + sha256: sha256, + spec_sha256: spec_sha256, + pusher: api_key.user, + pusher_api_key: api_key + ) + unless @rubygem.new_record? - if (version = @rubygem.find_version_from_spec spec) - republish_notification(version) - return false + # Return success for idempotent pushes + return notify("Gem was already pushed: #{version.to_title}", 200) if version.indexed? + + # If the gem is yanked, we can't repush it + # Additionally, we don't allow overwriting existing versions + if (existing = @rubygem.versions.find_by(number: version.number, platform: version.platform)) + return republish_notification(existing) end if @rubygem.name != name && @rubygem.indexed_versions? @@ -72,22 +141,17 @@ def find # Update the name to reflect a valid case change @rubygem.name = name + @version = version - sha256 = Digest::SHA2.base64digest(body.string) - - @version = @rubygem.versions.new number: spec.version.to_s, - canonical_number: spec.version.canonical_segments.join("."), - platform: spec.original_platform.to_s, - size: size, - sha256: sha256, - pusher: user + set_tags "gemcutter.rubygem.version" => @version.number, "gemcutter.rubygem.platform" => @version.platform + log_pushing true end # Overridden so we don't get megabytes of the raw data printing out def inspect - attrs = %i[@rubygem @user @message @code].map do |attr| + attrs = %i[@rubygem @owner @message @code].map do |attr| "#{attr}=#{instance_variable_get(attr).inspect}" end "" @@ -96,18 +160,16 @@ def inspect private def after_write - @version_id = version.id - version.rubygem.push_notifiable_owners.each do |notified_user| - Mailer.delay.gem_pushed(user.id, @version_id, notified_user.id) - end - Delayed::Job.enqueue Indexer.new, priority: PRIORITIES[:push] - rubygem.delay.index_document GemCachePurger.call(rubygem.name) - RackAttackReset.gem_push_backoff(@remote_ip) if @remote_ip.present? + RackAttackReset.gem_push_backoff(@request.remote_ip, owner.to_gid) if @request&.remote_ip.present? + AfterVersionWriteJob.new(version:).perform(version:) StatsD.increment "push.success" + Rstuf::AddJob.perform_later(version:) end def notify(message, code) + logger.info { { message:, code:, owner: owner.to_gid, api_key: api_key&.id, rubygem: rubygem&.name, version: version&.full_name } } + @message = message @code = code false @@ -116,36 +178,140 @@ def notify(message, code) def update rubygem.disown if rubygem.versions.indexed.count.zero? rubygem.update_attributes_from_gem_specification!(version, spec) - rubygem.create_ownership(user) - set_info_checksum + + if rubygem.unowned? + case owner + when User + rubygem.create_ownership(owner) + else + pending_publisher = find_pending_trusted_publisher + return notify_unauthorized if pending_publisher.blank? + + rubygem.transaction do + logger.info { "Reifying pending publisher" } + rubygem.create_ownership(pending_publisher.user) + owner.rubygem_trusted_publishers.create!(rubygem: rubygem) + end + end + end true rescue ActiveRecord::RecordInvalid, ActiveRecord::Rollback, ActiveRecord::RecordNotUnique false end - def set_info_checksum - checksum = GemInfo.new(rubygem.name).info_checksum - version.update_attribute :info_checksum, checksum - end - def republish_notification(version) if version.indexed? notify("Repushing of gem versions is not allowed.\n" \ - "Please use `gem yank` to remove bad gem releases.", 409) + "Please bump the version number and push a new different release.\n" \ + "See also `gem yank` if you want to unpublish the bad release.", 409) + elsif version.deletion.nil? + notify("It appears that #{version.full_name} did not finish pushing.\n" \ + "Please contact support@rubygems.org for assistance if you pushed this gem more than a minute ago.", 409) else - different_owner = "pushed by a previous owner of this gem " unless version.rubygem.owners.include?(@user) + different_owner = "pushed by a previous owner of this gem " unless owner.owns_gem?(version.rubygem) notify("A yanked version #{different_owner}already exists (#{version.full_name}).\n" \ - "Repushing of gem versions is not allowed. Please use a new version and retry", 409) + "Repushing of gem versions is not allowed. Please use a new version and retry", 409) end end def notify_unauthorized - if rubygem.unconfirmed_ownership?(user) - notify("You do not have permission to push to this gem. "\ - "Please confirm the ownership by clicking on the confirmation link sent your email #{user.email}", 403) + if !api_key.user? + notify("You are not allowed to push this gem.", 403) + elsif rubygem.unconfirmed_ownership?(owner) + notify("You do not have permission to push to this gem. " \ + "Please confirm the ownership by clicking on the confirmation link sent your email #{owner.email}", 403) else - notify("You do not have permission to push to this gem. Ask an owner to add you with: gem owner #{rubygem.name} --add #{user.email}", 403) + notify("You do not have permission to push to this gem. Ask an owner to add you with: gem owner #{rubygem.name} --add #{owner.email}", 403) + end + end + + def gem_security_policy + @gem_security_policy ||= begin + # Verify that the gem signatures match the certificate chain (if present) + policy = PushPolicy.dup + # Silence warnings from the verification + stream = StringIO.new + policy.ui = Gem::StreamUI.new(stream, stream, stream, false) + policy + end + end + + def validate_signature_exists? + return true if @spec.cert_chain.empty? + + signatures = @files.select { |file| file[/\.sig$/] } + + expected_signatures = %w[metadata.gz.sig data.tar.gz.sig checksums.yaml.gz.sig] + expected_signatures.difference(signatures).empty? + end + + def version_mfa_required? + ActiveRecord::Type::Boolean.new.cast(spec.metadata["rubygems_mfa_required"]) + end + + # we validate that the version full_name == spec.original_name + def write_gem(body, spec_contents) + gem_path = "gems/#{@version.gem_file_name}" + gem_contents = body.string + + spec_path = "quick/Marshal.4.8/#{@version.full_name}.gemspec.rz" + + # do all processing _before_ we upload anything to S3, so we lower the chances of orphaned files + RubygemFs.instance.store(gem_path, gem_contents, checksum_sha256: version.sha256) + RubygemFs.instance.store(spec_path, spec_contents, checksum_sha256: version.spec_sha256) + + Fastly.purge(path: gem_path) + Fastly.purge(path: spec_path) + end + + def log_pushing + logger.info do + # this is needed because the version can be invalid! + version = + begin + @version.as_json + rescue StandardError + { + number: @version.number, + platform: @version.platform + } + end + + { message: "Pushing gem", version:, rubygem: @version.rubygem.name, pusher: owner.as_json } end end + + def validate_spec + spec.send(:invalidate_memoized_attributes) + + spec = self.spec.dup + + cert_chain = spec.cert_chain + + spec.abbreviate + spec.sanitize + + # make sure we validate the cert chain, which gets snipped in abbreviate + spec.cert_chain = cert_chain + + # Silence warnings from the verification + stream = StringIO.new + policy = SpecificationPolicy.new(spec) + policy.ui = Gem::StreamUI.new(stream, stream, stream, false) + policy.validate(false) + end + + def serialize_spec + spec = self.spec.dup + spec.abbreviate + spec.sanitize + @spec_contents = Gem.deflate(Marshal.dump(spec)) + true + end + + def find_pending_trusted_publisher + return unless owner.class.module_parent_name == "OIDC::TrustedPublisher" + owner.pending_trusted_publishers.unexpired.rubygem_name_is(rubygem.name).first + end end diff --git a/app/models/rubygem.rb b/app/models/rubygem.rb index 00fa2c39532..03b5793cceb 100644 --- a/app/models/rubygem.rb +++ b/app/models/rubygem.rb @@ -8,124 +8,169 @@ class Rubygem < ApplicationRecord has_many :owners_including_unconfirmed, through: :ownerships_including_unconfirmed, source: :user has_many :push_notifiable_owners, ->(gem) { gem.owners.push_notifiable_owners }, through: :ownerships, source: :user has_many :ownership_notifiable_owners, ->(gem) { gem.owners.ownership_notifiable_owners }, through: :ownerships, source: :user + has_many :ownership_request_notifiable_owners, ->(gem) { gem.owners.ownership_request_notifiable_owners }, through: :ownerships, source: :user has_many :subscriptions, dependent: :destroy has_many :subscribers, through: :subscriptions, source: :user has_many :versions, dependent: :destroy, validate: false - has_one :latest_version, -> { where(latest: true).order(:position) }, class_name: "Version", inverse_of: :rubygem + has_one :latest_version, -> { latest.order(:position) }, class_name: "Version", inverse_of: :rubygem has_many :web_hooks, dependent: :destroy has_one :linkset, dependent: :destroy has_one :gem_download, -> { where(version_id: 0) }, inverse_of: :rubygem + has_many :ownership_calls, -> { opened }, dependent: :destroy, inverse_of: :rubygem + has_many :ownership_requests, -> { opened }, dependent: :destroy, inverse_of: :rubygem + has_many :audits, as: :auditable, inverse_of: :auditable + has_many :link_verifications, as: :linkable, inverse_of: :linkable, dependent: :destroy + has_many :oidc_rubygem_trusted_publishers, class_name: "OIDC::RubygemTrustedPublisher", inverse_of: :rubygem, dependent: :destroy + has_many :incoming_dependencies, -> { where(versions: { indexed: true, position: 0 }) }, class_name: "Dependency", inverse_of: :rubygem + has_many :reverse_dependencies, through: :incoming_dependencies, source: :version_rubygem + has_many :reverse_development_dependencies, -> { merge(Dependency.development) }, through: :incoming_dependencies, source: :version_rubygem + has_many :reverse_runtime_dependencies, -> { merge(Dependency.runtime) }, through: :incoming_dependencies, source: :version_rubygem + + # needs to come last so its dependent: :destroy works, since yanking a version + # will create an event + include Events::Recordable + + has_one :most_recent_version, + lambda { + order(Arel.sql("case when #{quoted_table_name}.latest AND #{quoted_table_name}.platform = 'ruby' then 2 else 1 end desc")) + .order(Arel.sql("case when #{quoted_table_name}.latest then #{quoted_table_name}.number else NULL end desc")) + .order(id: :desc) + }, + class_name: "Version", inverse_of: :rubygem - validate :ensure_name_format, if: :needs_name_validation? validates :name, length: { maximum: Gemcutter::MAX_FIELD_LENGTH }, presence: true, uniqueness: { case_sensitive: false }, + name_format: true, if: :needs_name_validation? - validate :blacklist_names_exclusion + validate :reserved_names_exclusion, if: :needs_name_validation? validate :protected_gem_typo, on: :create, unless: -> { Array(validation_context).include?(:typo_exception) } after_create :update_unresolved - before_destroy :mark_unresolved - # TODO: Remove this once we move to GemDownload only after_create :create_gem_download + before_destroy :mark_unresolved + + MFA_RECOMMENDED_THRESHOLD = 165_000_000 + MFA_REQUIRED_THRESHOLD = 180_000_000 + + scope :mfa_recommended, -> { joins(:gem_download).where("gem_downloads.count > ?", MFA_RECOMMENDED_THRESHOLD) } + scope :mfa_required, -> { joins(:gem_download).where("gem_downloads.count > ?", MFA_REQUIRED_THRESHOLD) } + def create_gem_download GemDownload.create!(count: 0, rubygem_id: id, version_id: 0) end - def self.with_versions + scope :with_versions, lambda { where(indexed: true) - end + } - def self.with_one_version + scope :without_versions, lambda { + where(indexed: false) + } + + scope :with_one_version, lambda { select("rubygems.*") .joins(:versions) .group(column_names.map { |name| "rubygems.#{name}" }.join(", ")) .having("COUNT(versions.id) = 1") - end + } - def self.name_is(name) + scope :name_is, lambda { |name| sensitive = where(name: name.strip).limit(1) return sensitive unless sensitive.empty? where("UPPER(name) = UPPER(?)", name.strip).limit(1) - end + } - def self.name_starts_with(letter) + scope :name_starts_with, lambda { |letter| where("UPPER(name) LIKE UPPER(?)", "#{letter}%") - end + } - def self.total_count - Rubygem.with_versions.count - end + scope :total_count, lambda { + with_versions.count + } - def self.latest(limit = 5) + scope :latest, lambda { |limit = 5| with_one_version.order(created_at: :desc).limit(limit) - end + } - def self.downloaded(limit = 5) + scope :downloaded, lambda { |limit = 5| with_versions.by_downloads.limit(limit) - end + } - def self.letter(letter) + scope :letter, lambda { |letter| name_starts_with(letter).by_name.with_versions - end - - def self.letterize(letter) - /\A[A-Za-z]\z/.match?(letter) ? letter.upcase : "A" - end + } - def self.by_name + scope :by_name, lambda { order(name: :asc) - end + } - def self.by_downloads + scope :by_downloads, lambda { joins(:gem_download).order("gem_downloads.count DESC") - end - - def self.current_rubygems_release - rubygem = find_by(name: "rubygems-update") - rubygem && rubygem.versions.release.indexed.latest.first - end + } - def self.news(days) + scope :news, lambda { |days| joins(:latest_version) .where("versions.created_at BETWEEN ? AND ?", days.ago.in_time_zone, Time.zone.now) .group(:id) .order("MAX(versions.created_at) DESC") - end + } - def self.popular(days) + scope :popular, lambda { |days| joins(:gem_download).order("MAX(gem_downloads.count) DESC").news(days) + } + + def self.letterize(letter) + /\A[A-Za-z]\z/.match?(letter) ? letter.upcase : "A" + end + + def self.current_rubygems_release + rubygem = find_by(name: "rubygems-update") + rubygem && rubygem.versions.release.indexed.latest.first end def all_errors(version = nil) - [self, linkset, version].compact.map do |ar| + [self, linkset, version].compact.flat_map do |ar| ar.errors.full_messages - end.flatten.join(", ") + end.join(", ") end - def public_versions(limit = nil) - versions.includes(:gem_download).by_position.published(limit) - end + has_many :public_versions, -> { by_position.published }, class_name: "Version", inverse_of: :rubygem def public_versions_with_extra_version(extra_version) - versions = public_versions(5).to_a + versions = public_versions.limit(5).to_a versions << extra_version versions.uniq.sort_by(&:position) end + # NB: this intentionally does not default the platform to ruby. + # Without platform, finds the most recent version by (position, created_at) ignoring platform. + def find_public_version(number, platform = nil) + if platform + public_versions.find_by(number:, platform:) + else + public_versions.find_by(number:) + end + end + def public_version_payload(number, platform = nil) - version = - if platform - public_versions.find_by(number: number, platform: platform) - else - public_versions.find_by(number: number) - end + version = find_public_version(number, platform) payload(version).merge!(version.as_json) if version end + def find_version!(number:, platform:) + platform = platform.presence || "ruby" + versions.find_by!(number: number, platform: platform) + end + + def find_version_by_slug!(slug) + full_name = "#{name}-#{slug}" + versions.find_by!(full_name: full_name) + end + def hosted? versions.count.nonzero? end @@ -143,12 +188,19 @@ def owned_by?(user) ownerships.exists?(user_id: user.id) end + def owned_by_with_role?(user, minimum_required_role) + return false if user.blank? + ownerships.user_with_minimum_role(user, minimum_required_role).exists? + rescue KeyError + false + end + def unconfirmed_ownerships ownerships_including_unconfirmed.unconfirmed end def unconfirmed_ownership?(user) - unconfirmed_ownerships.where(user: user).exists? + unconfirmed_ownerships.exists?(user: user) end def to_s @@ -159,17 +211,13 @@ def downloads gem_download&.count || 0 end - def most_recent_version - versions.most_recent - end - def links(version = most_recent_version) Links.new(self, version) end - def payload(version = most_recent_version, protocol = Gemcutter::PROTOCOL, host_with_port = Gemcutter::HOST) + def payload(version = most_recent_version, protocol = Gemcutter::PROTOCOL, host_with_port = Gemcutter::HOST) # rubocop:disable Metrics/MethodLength versioned_links = links(version) - deps = version.dependencies.to_a + deps = version.dependencies.to_a.select(&:rubygem) { "name" => name, "downloads" => downloads, @@ -183,8 +231,9 @@ def payload(version = most_recent_version, protocol = Gemcutter::PROTOCOL, host_ "metadata" => version.metadata, "yanked" => version.yanked?, "sha" => version.sha256_hex, + "spec_sha" => version.spec_sha256_hex, "project_uri" => "#{protocol}://#{host_with_port}/gems/#{name}", - "gem_uri" => "#{protocol}://#{host_with_port}/gems/#{version.full_name}.gem", + "gem_uri" => "#{protocol}://#{host_with_port}/gems/#{version.gem_file_name}", "homepage_uri" => versioned_links.homepage_uri, "wiki_uri" => versioned_links.wiki_uri, "documentation_uri" => versioned_links.documentation_uri, @@ -194,22 +243,20 @@ def payload(version = most_recent_version, protocol = Gemcutter::PROTOCOL, host_ "changelog_uri" => versioned_links.changelog_uri, "funding_uri" => versioned_links.funding_uri, "dependencies" => { - "development" => deps.select { |r| r.rubygem && r.scope == "development" }, - "runtime" => deps.select { |r| r.rubygem && r.scope == "runtime" } + "development" => deps.select { |r| r.scope == "development" }, + "runtime" => deps.select { |r| r.scope == "runtime" } } } end - def as_json(*) - payload - end + delegate :as_json, :to_yaml, to: :payload def to_xml(options = {}) payload.to_xml(options.merge(root: "rubygem")) end - def to_param - name.remove(/[^#{Patterns::ALLOWED_CHARACTERS}]/) + def slug + name.remove(/[^#{Patterns::ALLOWED_CHARACTERS}]/o) end def pushable? @@ -217,7 +264,11 @@ def pushable? end def create_ownership(user) - Ownership.create_confirmed(self, user) if unowned? + Ownership.create_confirmed(self, user, user) if unowned? + end + + def ownership_call + ownership_calls.find_by(status: "opened") end def update_versions!(version, spec) @@ -227,11 +278,13 @@ def update_versions!(version, spec) def update_dependencies!(version, spec) spec.dependencies.each do |dependency| version.dependencies.create!(gem_dependency: dependency) + rescue ActiveRecord::RecordInvalid => e + # ActiveRecord can't chain a nested error here, so we have to add and reraise + e.record.errors.errors.each do |error| + errors.import(error, attribute: "dependency.#{error.attribute}") + end + raise end - rescue ActiveRecord::RecordInvalid => e - # ActiveRecord can't chain a nested error here, so we have to add and reraise - errors[:base] << e.message - raise e end def update_linkset!(spec) @@ -263,9 +316,7 @@ def reorder_versions .indexed .group_by(&:platform) - versions_of_platforms.each_value do |platforms| - Version.find(platforms.max.id).update_column(:latest, true) - end + Version.default_scoped.where(id: versions_of_platforms.values.map! { |v| v.max.id }).update_all(latest: true) end def refresh_indexed! @@ -273,17 +324,17 @@ def refresh_indexed! end def disown - ownerships_including_unconfirmed.each(&:delete) + ownerships_including_unconfirmed.find_each(&:delete) ownerships_including_unconfirmed.clear - end - def find_version_from_spec(spec) - versions.find_by_number_and_platform(spec.version.to_s, spec.original_platform.to_s) + oidc_rubygem_trusted_publishers.find_each(&:delete) + oidc_rubygem_trusted_publishers.clear end def find_or_initialize_version_from_spec(spec) version = versions.find_or_initialize_by(number: spec.version.to_s, - platform: spec.original_platform.to_s) + platform: spec.original_platform.to_s, + gem_platform: spec.platform.to_s) version.rubygem = self version end @@ -291,21 +342,41 @@ def find_or_initialize_version_from_spec(spec) # returns days left before the reserved namespace will be released # 100 + 1 days are added so that last_protected_day / 1.day = 1 def protected_days - (updated_at + 101.days - Time.zone.now).to_i / 1.day + days = (updated_at - 101.days.ago).to_i / 1.day + days.positive? ? days : 0 + end + + def release_reserved_namespace! + update_attribute(:updated_at, 101.days.ago) + end + + def metadata_mfa_required? + latest_version&.rubygems_metadata_mfa_required? + end + + def mfa_requirement_satisfied_for?(user) + user.mfa_enabled? || !metadata_mfa_required? + end + + def version_manifest(number, platform = nil) + VersionManifest.new(gem: name, number: number, platform: platform) end - def reverse_dependencies - self.class.joins("inner join versions as v on v.rubygem_id = rubygems.id - inner join dependencies as d on d.version_id = v.id").where("v.indexed = 't' - and v.position = 0 and d.rubygem_id = ?", id) + def file_content(fingerprint) + RubygemContents.new(gem: name).get(fingerprint) end - def reverse_development_dependencies - reverse_dependencies.where("d.scope = 'development'") + def yank_versions!(version_id: nil, force: false) + security_user = User.security_user + versions_to_yank = version_id ? versions.where(id: version_id) : versions + + versions_to_yank.find_each do |version| + security_user.deletions.create!(version: version, force:) unless version.yanked? + end end - def reverse_runtime_dependencies - reverse_dependencies.where("d.scope ='runtime'") + def linkable_verification_uri + URI.join("https://rubygems.org/gems/", name) end private @@ -316,24 +387,12 @@ def not_protected? updated_at < 100.days.ago || created_at > 30.days.ago end - def ensure_name_format - if name.class != String - errors.add :name, "must be a String" - elsif !/[a-zA-Z]+/.match?(name) - errors.add :name, "must include at least one letter" - elsif !NAME_PATTERN.match?(name) - errors.add :name, "can only include letters, numbers, dashes, and underscores" - elsif /\A[#{Regexp.escape(Patterns::SPECIAL_CHARACTERS)}]+/.match?(name) - errors.add :name, "can not begin with a period, dash, or underscore" - end - end - def needs_name_validation? new_record? || name_changed? end - def blacklist_names_exclusion - return unless GEM_NAME_BLACKLIST.include? name.downcase + def reserved_names_exclusion + return unless GemNameReservation.reserved?(name) errors.add :name, "'#{name}' is a reserved gem name." end @@ -355,7 +414,7 @@ def mark_unresolved end def bulk_reorder_versions - numbers = reload.versions.sort.reverse.map(&:number).uniq + numbers = reload.versions.pluck(:number).uniq.sort_by { |n| Gem::Version.new(n) }.reverse ids = [] positions = [] diff --git a/app/models/rubygem_contents.rb b/app/models/rubygem_contents.rb new file mode 100644 index 00000000000..cac75db024e --- /dev/null +++ b/app/models/rubygem_contents.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +class RubygemContents + CHECKSUMS_ROOT_FORMAT = "gems/%{gem}/checksums/" + CHECKSUMS_KEY_FORMAT = "gems/%{gem}/checksums/%{version}.%{format}" + CONTENT_ROOT_FORMAT = "gems/%{gem}/contents/" + CONTENT_KEY_FORMAT = "gems/%{gem}/contents/%{fingerprint}" + PATH_ROOT_FORMAT = "gems/%{gem}/paths/%{version}/" + PATH_KEY_FORMAT = "gems/%{gem}/paths/%{version}/%{path}" + SPEC_KEY_FORMAT = "gems/%{gem}/specs/%{gem}-%{version}.gemspec" + + attr_reader :gem + + def initialize(gem:) + raise ArgumentError, "gem must be Rubygem#name" unless gem.try(:match?, Rubygem::NAME_PATTERN) + @gem = gem + end + + def fs + RubygemFs.contents + end + + def get(fingerprint) + return if fingerprint.blank? + fs.get key(fingerprint) + end + + def keys + fs.each_key(prefix: root).map { |key| key.delete_prefix root } + end + + def store(entry) + return unless entry.body_persisted? + fs.store( + key(entry.fingerprint), + entry.body, + content_type: entry.content_type, + content_length: entry.size, + checksum_sha256: entry.base64_sha256, + checksum_algorithm: "SHA256" + ) + entry.fingerprint + end + + def root + format CONTENT_ROOT_FORMAT, gem: gem + end + + def key(fingerprint) + format CONTENT_KEY_FORMAT, gem: gem, fingerprint: fingerprint + end + + def ==(other) + other.is_a?(self.class) && other.gem == gem + end +end diff --git a/app/models/rubygem_contents/entry.rb b/app/models/rubygem_contents/entry.rb new file mode 100644 index 00000000000..396d205030a --- /dev/null +++ b/app/models/rubygem_contents/entry.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +class RubygemContents::Entry + class InvalidMetadata < RuntimeError; end + + SIZE_LIMIT = 500.megabytes + MIME_TEXTUAL_SUBTYPES = %w[json ld+json x-csh x-sh x-httpd-php xhtml+xml xml].freeze + + class << self + def from_tar_entry(entry) + attrs = { + size: entry.size, + path: entry.full_name, + file_mode: entry.header.mode.to_fs(8) + } + + if entry.size > SIZE_LIMIT + head = entry.read(4096) + mime = Magic.buffer(head, Magic::MIME) + return new(mime: mime, **attrs) + end + + # Using the linkname as the body, like git, makes it easier to show and diff symlinks. Thanks git! + body = attrs[:linkname] = entry.header.linkname if entry.symlink? + # read immediately because we're parsing a tar.gz and it shares a single IO across all entries. + body ||= entry.read || "" + + new( + body: body, + mime: Magic.buffer(body, Magic::MIME), + sha256: Digest::SHA256.hexdigest(body), + **attrs + ) + end + + def from_metadata(metadata, &) + attrs = metadata.to_h.symbolize_keys.slice(:body_persisted, :file_mode, :lines, :linkname, :mime, :path, :sha256, :size) + raise InvalidMetadata, "missing required keys: #{attrs.inspect}" if attrs[:path].blank? || attrs[:size].blank? + attrs[:lines] = attrs[:lines]&.to_i + attrs[:body_persisted] = attrs[:body_persisted] == "true" + new(persisted: true, **attrs, &) + end + end + + attr_reader :path, :linkname, :file_mode, :lines, :sha256, :mime, :size + alias fingerprint sha256 + alias content_type mime + alias content_length size + alias bytesize size + + def initialize(path:, size:, persisted: false, **attrs, &reader) + @path = path + @size = size.to_i + @persisted = persisted + + @body_persisted, @file_mode, @lines, @linkname, @mime, @sha256 = + attrs.values_at(:body_persisted, :file_mode, :lines, :linkname, :mime, :sha256) + + if @persisted + @reader = reader if @body_persisted + else + @body_persisted = sha256.present? && !large? && text? + @body = attrs[:body] if @body_persisted + @lines = @body&.lines&.count unless symlink? + end + end + + def persisted? + @persisted + end + + def body_persisted? + @body_persisted + end + + def symlink? + linkname.present? + end + + def file? + !symlink? + end + + def large? + size > SIZE_LIMIT + end + + def empty? + !symlink? && size.zero? + end + + def text? + return false unless mime + return true if empty? + return false if mime.end_with?("charset=binary") + media_type, sub_type = mime.split(";").first.split("/") + return true if media_type == "text" + return false if media_type != "application" + true if MIME_TEXTUAL_SUBTYPES.include?(sub_type) + end + + def body + @body = @reader.call(self) if @reader + @reader = nil + @body + end + + def metadata + { + "path" => path, + "size" => size.to_s, + "mime" => mime, + "lines" => lines&.to_s, + "sha256" => sha256, + "linkname" => linkname, + "file_mode" => file_mode, + "body_persisted" => body_persisted? ? "true" : "false" + }.compact + end + + def base64_sha256 + sha256.presence && [[sha256].pack("H*")].pack("m0") + end + + def ==(other) + other.is_a?(self.class) && other.metadata == metadata + end +end diff --git a/app/models/sendgrid_event.rb b/app/models/sendgrid_event.rb index efe4ef31a9e..df5e7f89737 100644 --- a/app/models/sendgrid_event.rb +++ b/app/models/sendgrid_event.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true class SendgridEvent < ApplicationRecord + enum :status, %i[pending processed].index_with(&:to_s) + enum :event_type, %i[delivered dropped bounce].index_with(&:to_s) + # To make allowances for occasional inbox down time, this counts a maximum of one fail per day, # e.g.: # @@ -22,10 +25,8 @@ def self.fails_since_last_delivery(email) end def self.process_later(payload) - return if where(sendgrid_id: payload[:sg_event_id]).exists? - transaction do - event = create!( + sendgrid_event = create!( sendgrid_id: payload[:sg_event_id], email: payload[:email], event_type: payload[:event], @@ -33,8 +34,10 @@ def self.process_later(payload) payload: payload, status: "pending" ) - delay.process(event.id) + ProcessSendgridEventJob.perform_later(sendgrid_event:) end + rescue ActiveRecord::RecordNotUnique + nil end def self.process(id) @@ -49,11 +52,7 @@ def process fails_count = self.class.fails_since_last_delivery(email) User.where(email: email).update_all(mail_fails: fails_count) end - update_attribute(:status, "processed") + update_attribute(:status, :processed) end end - - def pending? - status == "pending" - end end diff --git a/app/models/specification_policy.rb b/app/models/specification_policy.rb new file mode 100644 index 00000000000..9729cc321f4 --- /dev/null +++ b/app/models/specification_policy.rb @@ -0,0 +1,11 @@ +class SpecificationPolicy < Gem::SpecificationPolicy + def error(statement) + return if statement.start_with?("#{Gem::SpecificationPolicy::LAZY} is not a") + + super + end + + def warning(statement) + # do nothing + end +end diff --git a/app/models/user.rb b/app/models/user.rb index fc3931f4c61..a740033e4a3 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,79 +1,134 @@ class User < ApplicationRecord + include UserMultifactorMethods include Clearance::User + include Gravtastic + include Events::Recordable is_gravtastic default: "retro" - PERMITTED_ATTRS = %i[ - bio - email - handle - hide_email - location - password - website - twitter_username - ].freeze - + include Discard::Model + self.discard_column = :deleted_at + + default_scope { not_deleted } + + before_save :_generate_confirmation_token_no_reset_unconfirmed_email, if: :will_save_change_to_unconfirmed_email? + before_create :_generate_confirmation_token_no_reset_unconfirmed_email + after_create :record_create_event + after_update :record_email_update_event, if: :email_was_updated? + after_update :record_email_verified_event, if: -> { saved_change_to_email? && email_confirmed? } + after_update :record_password_update_event, if: :saved_change_to_encrypted_password? + before_discard :yank_gems + before_discard :expire_all_api_keys + before_discard :destroy_associations_for_discard + before_discard :clear_personal_attributes + after_discard :send_deletion_complete_email before_destroy :yank_gems + scope :not_deleted, -> { kept } + scope :deleted, -> { with_discarded.discarded } + scope :with_deleted, -> { with_discarded } + has_many :ownerships, -> { confirmed }, dependent: :destroy, inverse_of: :user has_many :rubygems, through: :ownerships, source: :rubygem has_many :subscriptions, dependent: :destroy has_many :subscribed_gems, -> { order("name ASC") }, through: :subscriptions, source: :rubygem + has_many :rubygems_downloaded, + -> { with_versions.joins(:gem_download).order(GemDownload.arel_table["count"].desc) }, + through: :ownerships, + source: :rubygem + + has_many :pushed_versions, -> { by_created_at }, dependent: :nullify, inverse_of: :pusher, class_name: "Version", foreign_key: :pusher_id + has_many :yanked_versions, through: :deletions, source: :version, inverse_of: :yanker + has_many :deletions, dependent: :nullify has_many :web_hooks, dependent: :destroy # used for deleting unconfirmed ownerships as well on user destroy has_many :unconfirmed_ownerships, -> { unconfirmed }, dependent: :destroy, inverse_of: :user, class_name: "Ownership" - has_many :api_keys, dependent: :destroy + has_many :api_keys, dependent: :destroy, inverse_of: :owner, as: :owner - before_save :generate_confirmation_token, if: :will_save_change_to_unconfirmed_email? - before_create :generate_confirmation_token + has_many :ownership_calls, -> { opened }, dependent: :destroy, inverse_of: :user + has_many :closed_ownership_calls, -> { closed }, dependent: :destroy, inverse_of: :user, class_name: "OwnershipCall" + has_many :ownership_requests, -> { opened }, dependent: :destroy, inverse_of: :user + has_many :closed_ownership_requests, -> { closed }, dependent: :destroy, inverse_of: :user, class_name: "OwnershipRequest" + has_many :approved_ownership_requests, -> { approved }, dependent: :destroy, inverse_of: :user, class_name: "OwnershipRequest" - validates :email, length: { maximum: Gemcutter::MAX_FIELD_LENGTH }, format: { with: URI::MailTo::EMAIL_REGEXP }, presence: true + has_many :audits, as: :auditable, dependent: :nullify + has_many :rubygem_events, through: :rubygems, source: :events - validates :handle, uniqueness: true, allow_nil: true - validates :handle, format: { - with: /\A[A-Za-z][A-Za-z_\-0-9]*\z/, - message: "must start with a letter and can only contain letters, numbers, underscores, and dashes" - }, allow_nil: true - validates :handle, length: { within: 2..40 }, allow_nil: true + has_many :oidc_api_key_roles, dependent: :nullify, class_name: "OIDC::ApiKeyRole", inverse_of: :user + has_many :oidc_id_tokens, through: :oidc_api_key_roles, class_name: "OIDC::IdToken", inverse_of: :user, source: :id_tokens + has_many :oidc_providers, through: :oidc_api_key_roles, class_name: "OIDC::Provider", inverse_of: :users, source: :provider + has_many :oidc_pending_trusted_publishers, class_name: "OIDC::PendingTrustedPublisher", inverse_of: :user, dependent: :destroy + has_many :oidc_rubygem_trusted_publishers, through: :rubygems, class_name: "OIDC::RubygemTrustedPublisher" + + has_many :memberships, dependent: :destroy + has_many :organizations, through: :memberships + + validates :email, length: { maximum: Gemcutter::MAX_FIELD_LENGTH }, format: { with: URI::MailTo::EMAIL_REGEXP }, presence: true, + uniqueness: { case_sensitive: false } + validates :unconfirmed_email, length: { maximum: Gemcutter::MAX_FIELD_LENGTH }, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true + + validates :handle, uniqueness: { case_sensitive: false }, allow_nil: true, if: :handle_changed? + validates :handle, format: { with: Patterns::HANDLE_PATTERN }, length: { within: 2..40 }, allow_nil: true + validate :unique_with_org_handle validates :twitter_username, format: { with: /\A[a-zA-Z0-9_]*\z/, message: "can only contain letters, numbers, and underscores" - }, allow_nil: true + }, allow_nil: true, length: { within: 0..20 } - validates :twitter_username, length: { within: 0..20 }, allow_nil: true validates :password, length: { within: 10..200 }, unpwn: true, - allow_nil: true, + allow_blank: true, # avoid double errors with can't be blank unless: :skip_password_validation? + + validates :full_name, length: { maximum: Gemcutter::MAX_FIELD_LENGTH }, allow_nil: true + validate :unconfirmed_email_uniqueness validate :toxic_email_domain, on: :create - enum mfa_level: { disabled: 0, ui_only: 1, ui_and_api: 2, ui_and_gem_signin: 3 }, _prefix: :mfa - def self.authenticate(who, password) - user = find_by(email: who.downcase) || find_by(handle: who) + # Avoid exceptions when string is invalid in the given encoding, _or_ cannot be converted + # to UTF-8. + who = who.encode(Encoding::UTF_8) + + user = find_by_email(who) || find_by(handle: who) user if user&.authenticated?(password) - rescue BCrypt::Errors::InvalidHash + rescue BCrypt::Errors::InvalidHash, Encoding::UndefinedConversionError nil end + def self.find_by_email(email) + find_by("lower(email) = lower(?)", email) + end + def self.find_by_slug!(slug) + raise ActiveRecord::RecordNotFound if slug.blank? find_by(id: slug) || find_by!(handle: slug) end def self.find_by_slug(slug) + return if slug.blank? find_by(id: slug) || find_by(handle: slug) end + def self.find_by_name!(name) + raise ActiveRecord::RecordNotFound if name.blank? + find_by_email(name) || find_by!(handle: name) + end + def self.find_by_name(name) - find_by(email: name) || find_by(handle: name) + return if name.blank? + find_by_email(name) || find_by(handle: name) + end + + def self.find_by_blocked(slug) + return if slug.blank? + find_by(blocked_email: slug) || find_by(handle: slug) end def self.push_notifiable_owners @@ -84,8 +139,18 @@ def self.ownership_notifiable_owners where(ownerships: { owner_notifier: true }) end - def self.without_mfa - where(mfa_level: "disabled") + def self.ownership_request_notifiable_owners + where(ownerships: { ownership_request_notifier: true }) + end + + def self.normalize_email(email) + email.to_s.gsub(/\s+/, "") + rescue ArgumentError + "" + end + + def self.security_user + find_by!(email: "security@rubygems.org") end def name @@ -113,22 +178,16 @@ def all_hooks def payload attrs = { "id" => id, "handle" => handle } - attrs["email"] = email unless hide_email + attrs["email"] = email if public_email? attrs end - def as_json(*) - payload - end + delegate :as_json, :to_yaml, to: :payload def to_xml(options = {}) payload.to_xml(options.merge(root: "user")) end - def to_yaml(*args) - payload.to_yaml(*args) - end - def encode_with(coder) coder.tag = nil coder.implicit = true @@ -140,11 +199,7 @@ def generate_api_key end def total_downloads_count - rubygems.to_a.sum(&:downloads) - end - - def rubygems_downloaded - rubygems.with_versions.sort_by { |rubygem| -rubygem.downloads } + rubygems.joins(:gem_download).sum(:count) end def total_rubygems_count @@ -152,7 +207,7 @@ def total_rubygems_count end def confirm_email! - update_email! if unconfirmed_email + return false if unconfirmed_email && !update_email update!(email_confirmed: true, confirmation_token: nil) end @@ -161,9 +216,14 @@ def valid_confirmation_token? token_expires_at > Time.zone.now end - def generate_confirmation_token - self.confirmation_token = Clearance::Token.new - self.token_expires_at = Time.zone.now + Gemcutter::EMAIL_TOKEN_EXPRIES_AFTER + def generate_confirmation_token(reset_unconfirmed_email: true) + self.unconfirmed_email = nil if reset_unconfirmed_email + self.confirmation_token = SecureRandom.hex(24) + self.token_expires_at = Time.zone.now + Gemcutter::EMAIL_TOKEN_EXPIRES_AFTER + end + + def _generate_confirmation_token_no_reset_unconfirmed_email + generate_confirmation_token(reset_unconfirmed_email: false) end def unconfirmed? @@ -186,79 +246,53 @@ def remember_me? remember_token_expires_at && remember_token_expires_at > Time.zone.now end - def mfa_enabled? - !mfa_disabled? - end - - def disable_mfa! - mfa_disabled! - self.mfa_seed = "" - self.mfa_recovery_codes = [] - save!(validate: false) - end - - def verify_and_enable_mfa!(seed, level, otp, expiry) - if expiry < Time.now.utc - errors.add(:base, I18n.t("multifactor_auths.create.qrcode_expired")) - elsif verify_digit_otp(seed, otp) - enable_mfa!(seed, level) - else - errors.add(:base, I18n.t("multifactor_auths.incorrect_otp")) - end - end - - def enable_mfa!(seed, level) - self.mfa_level = level - self.mfa_seed = seed - self.mfa_recovery_codes = Array.new(10).map { SecureRandom.hex(6) } - save!(validate: false) - end - - def mfa_gem_signin_authorized?(otp) - return true unless mfa_ui_and_gem_signin? || mfa_ui_and_api? - otp_verified?(otp) - end - - def mfa_api_authorized?(otp) - return true unless mfa_ui_and_api? - otp_verified?(otp) - end - - def otp_verified?(otp) - otp = otp.to_s - return true if verify_digit_otp(mfa_seed, otp) - - return false unless mfa_recovery_codes.include? otp - mfa_recovery_codes.delete(otp) - save!(validate: false) - end - def block! + original_email = email transaction do update_attribute(:email, "security+locked-#{SecureRandom.hex(4)}-#{display_handle.downcase}@rubygems.org") confirm_email! - disable_mfa! + disable_totp! update_attribute(:password, SecureRandom.alphanumeric) update!( remember_token: nil, remember_token_expires_at: nil, - api_key: nil + api_key: nil, + blocked_email: original_email ) + api_keys.expire_all! end end - private + def unblock! + raise ArgumentError, "User is not blocked" unless blocked? - def verify_digit_otp(seed, otp) - totp = ROTP::TOTP.new(seed) - return false unless totp.verify(otp, drift_behind: 30, drift_ahead: 30) + update!( + email: blocked_email, + blocked_email: nil + ) + end - save!(validate: false) + def blocked? + blocked_email.present? end - def update_email! - self.attributes = { email: unconfirmed_email, unconfirmed_email: nil, mail_fails: 0 } - save!(validate: false) + def owns_gem?(rubygem) + rubygem.owned_by?(self) + end + + def ld_context + LaunchDarkly::LDContext.create( + key: "user-key-#{id}", + kind: "user", + name: handle, + email: email + ) + end + + private + + def update_email + update(email: unconfirmed_email, unconfirmed_email: nil, mail_fails: 0) end def unconfirmed_email_uniqueness @@ -266,21 +300,82 @@ def unconfirmed_email_uniqueness end def unconfirmed_email_exists? - User.where(email: unconfirmed_email).exists? + User.exists?(email: unconfirmed_email) end def yank_gems versions_to_yank = only_owner_gems.map(&:versions).flatten versions_to_yank.each do |v| - deletions.create(version: v) + deletion = deletions.create(version: v) + next if deletion.persisted? + next unless deletion.ineligible? + deletion.record_yank_forbidden_event! end end def toxic_email_domain - domain = email.split("@").last + return unless (domain = email.split("@").last) toxic_domains_path = Pathname.new(Gemcutter::Application.config.toxic_domains_filepath) toxic = toxic_domains_path.exist? && toxic_domains_path.readlines.grep(/^#{Regexp.escape(domain)}$/).any? errors.add(:email, I18n.t("activerecord.errors.messages.blocked", domain: domain)) if toxic end + + def expire_all_api_keys + api_keys.expire_all! + end + + def destroy_associations_for_discard + ownerships.unscope(where: :confirmed_at).destroy_all + ownership_requests.update_all(status: :closed) + ownership_calls.unscope(where: :status).destroy_all + oidc_pending_trusted_publishers.destroy_all + subscriptions.destroy_all + web_hooks.destroy_all + webauthn_credentials.destroy_all + webauthn_verification&.destroy! + end + + def clear_personal_attributes + @email_before_discard = email + update!( + email: "deleted+#{id}@rubygems.org", + handle: nil, email_confirmed: false, + unconfirmed_email: nil, blocked_email: nil, + api_key: nil, confirmation_token: nil, remember_token: nil, + twitter_username: nil, webauthn_id: nil, full_name: nil, + totp_seed: nil, mfa_hashed_recovery_codes: nil, + mfa_level: :disabled, + password: SecureRandom.hex(20).encode("UTF-8") + ) + end + + def send_deletion_complete_email + Mailer.deletion_complete(@email_before_discard).deliver_later + end + + def record_create_event + record_event!(Events::UserEvent::CREATED, email:) + end + + def email_was_updated? + (saved_change_to_unconfirmed_email? || saved_change_to_email?) && + email != attribute_before_last_save(:unconfirmed_email) + end + + def record_email_update_event + record_event!(Events::UserEvent::EMAIL_ADDED, email: unconfirmed_email) + end + + def record_email_verified_event + record_event!(Events::UserEvent::EMAIL_VERIFIED, email:) + end + + def record_password_update_event + record_event!(Events::UserEvent::PASSWORD_CHANGED) + end + + def unique_with_org_handle + errors.add(:handle, "has already been taken") if handle && Organization.where("lower(handle) = lower(?)", handle).any? + end end diff --git a/app/models/user/with_private_fields.rb b/app/models/user/with_private_fields.rb new file mode 100644 index 00000000000..164836e3dd9 --- /dev/null +++ b/app/models/user/with_private_fields.rb @@ -0,0 +1,18 @@ +# This enables us to return private fields, like mfa_level, to an authenticated user +# so that they may retrieve this information about their own profile without it +# being exposed to all users in their public profile +class User::WithPrivateFields < User + def payload + super.merge({ "mfa" => mfa_level, "warning" => mfa_warning }) + end + + private + + def mfa_warning + if mfa_recommended_not_yet_enabled? + I18n.t("multifactor_auths.api.mfa_recommended_not_yet_enabled").chomp + elsif mfa_recommended_weak_level_enabled? + I18n.t("multifactor_auths.api.mfa_recommended_weak_level_enabled").chomp + end + end +end diff --git a/app/models/version.rb b/app/models/version.rb index d1d028afa0f..cf29ffbb611 100644 --- a/app/models/version.rb +++ b/app/models/version.rb @@ -1,33 +1,65 @@ require "digest/sha2" -class Version < ApplicationRecord - MAX_TEXT_FIELD_LENGTH = 64_000 +class Version < ApplicationRecord # rubocop:disable Metrics/ClassLength + RUBYGEMS_IMPORT_DATE = Date.parse("2009-07-25") belongs_to :rubygem, touch: true - has_many :dependencies, -> { order("rubygems.name ASC").includes(:rubygem) }, dependent: :destroy, inverse_of: "version" + has_many :dependencies, lambda { + order(Rubygem.arel_table["name"].asc).includes(:rubygem).references(:rubygem) + }, dependent: :destroy, inverse_of: "version" + has_many :audits, as: :auditable, inverse_of: :auditable, dependent: :nullify has_one :gem_download, inverse_of: :version, dependent: :destroy - belongs_to :pusher, class_name: "User", foreign_key: "pusher_id", inverse_of: false, optional: true + belongs_to :pusher, class_name: "User", inverse_of: :pushed_versions, optional: true + belongs_to :pusher_api_key, class_name: "ApiKey", inverse_of: :pushed_versions, optional: true + has_one :deletion, dependent: :delete, inverse_of: :version, required: false + has_one :yanker, through: :deletion, source: :user, inverse_of: :yanked_versions, required: false - before_save :update_prerelease, if: :number_changed? + before_validation :set_canonical_number, if: :number_changed? before_validation :full_nameify! + before_validation :gem_full_nameify! + before_save :create_link_verifications, if: :metadata_changed? + before_save :update_prerelease, if: :number_changed? + # TODO: Remove this once we move to GemDownload only + after_create :create_gem_download + after_create :record_push_event after_save :reorder_versions, if: -> { saved_change_to_indexed? || saved_change_to_id? } + after_save :enqueue_web_hook_jobs, if: -> { saved_change_to_indexed? && (!saved_change_to_id? || indexed?) } after_save :refresh_rubygem_indexed, if: -> { saved_change_to_indexed? || saved_change_to_id? } - serialize :licenses - serialize :requirements - - validates :number, length: { maximum: Gemcutter::MAX_FIELD_LENGTH }, format: { with: /\A#{Gem::Version::VERSION_PATTERN}\z/ } - validates :platform, length: { maximum: Gemcutter::MAX_FIELD_LENGTH }, format: { with: Rubygem::NAME_PATTERN } - validates :full_name, presence: true, uniqueness: { case_sensitive: false } + serialize :licenses, coder: YAML + serialize :requirements, coder: YAML + serialize :cert_chain, coder: CertificateChainSerializer + + validates :number, length: { maximum: Gemcutter::MAX_FIELD_LENGTH }, format: { with: Patterns::VERSION_PATTERN } + validates :platform, length: { maximum: Gemcutter::MAX_FIELD_LENGTH }, format: { with: Patterns::NAME_PATTERN } + validates :gem_platform, length: { maximum: Gemcutter::MAX_FIELD_LENGTH }, format: { with: Patterns::NAME_PATTERN }, + if: -> { validation_context == :create || gem_platform_changed? } + validates :full_name, presence: true, uniqueness: { case_sensitive: false }, + if: -> { validation_context == :create || full_name_changed? } + validates :gem_full_name, presence: true, uniqueness: { case_sensitive: false }, + if: -> { validation_context == :create || gem_full_name_changed? } validates :rubygem, presence: true - validates :required_rubygems_version, :licenses, length: { maximum: Gemcutter::MAX_FIELD_LENGTH }, allow_blank: true - validates :description, :summary, :authors, :requirements, length: { minimum: 0, maximum: MAX_TEXT_FIELD_LENGTH }, allow_blank: true + validates :licenses, length: { maximum: Gemcutter::MAX_FIELD_LENGTH }, allow_blank: true + validates :required_rubygems_version, :required_ruby_version, length: { maximum: Gemcutter::MAX_FIELD_LENGTH }, + gem_requirements: true, allow_blank: true + validates :description, :summary, :authors, :requirements, :cert_chain, + length: { minimum: 0, maximum: Gemcutter::MAX_TEXT_FIELD_LENGTH }, + allow_blank: true + validates :sha256, :spec_sha256, format: { with: Patterns::BASE64_SHA256_PATTERN }, allow_nil: true + + validates :number, :platform, :gem_platform, :full_name, :gem_full_name, :canonical_number, + name_format: { requires_letter: false }, + if: -> { validation_context == :create || number_changed? || platform_changed? }, + presence: true validate :unique_canonical_number, on: :create validate :platform_and_number_are_unique, on: :create + validate :gem_platform_and_number_are_unique, on: :create + validate :original_platform_resolves_to_gem_platform, on: %i[create platform_changed? gem_platform_changed?] validate :authors_format, on: :create - validate :metadata_links_format + validate :metadata_links_format, if: -> { validation_context == :create || metadata_changed? } validate :metadata_attribute_length + validate :no_dashes_in_version_number, on: :create class AuthorType < ActiveModel::Type::String def cast_value(value) @@ -40,8 +72,6 @@ def cast_value(value) end attribute :authors, AuthorType.new - # TODO: Remove this once we move to GemDownload only - after_create :create_gem_download def create_gem_download GemDownload.create!(count: 0, rubygem_id: rubygem_id, version_id: id) end @@ -71,6 +101,10 @@ def self.subscribed_to_by(user) .by_created_at end + def self.created_after(time) + where(arel_table[:created_at].gt(Arel::Nodes::BindParam.new(time))) + end + def self.latest where(latest: true) end @@ -123,13 +157,9 @@ def self.rows_for_prerelease_index .pluck("rubygems.name", :number, :platform) end - def self.most_recent - latest.find_by(platform: "ruby") || latest.order(number: :desc).first || last - end - # This method returns the new versions for brand new rubygems def self.new_pushed_versions(limit = 5) - subquery = <<-SQL + subquery = <<~SQL.squish versions.rubygem_id IN (SELECT versions.rubygem_id FROM versions GROUP BY versions.rubygem_id HAVING COUNT(versions.rubygem_id) = 1 ORDER BY versions.rubygem_id DESC LIMIT :limit) @@ -139,37 +169,31 @@ def self.new_pushed_versions(limit = 5) end def self.just_updated(limit = 5) - subquery = <<-SQL - versions.rubygem_id IN (SELECT versions.rubygem_id - FROM versions - WHERE versions.indexed = 'true' - GROUP BY versions.rubygem_id - HAVING COUNT(versions.id) > 1 - ORDER BY MAX(created_at) DESC LIMIT :limit) - SQL - - where(subquery, limit: limit) + where(rubygem_id: Version.default_scoped + .select(:rubygem_id) + .indexed + .created_after(6.months.ago) + .group(:rubygem_id) + .having(arel_table["id"].count.gt(1)) + .order(arel_table["created_at"].maximum.desc) + .limit(limit)) .joins(:rubygem) .indexed .by_created_at .limit(limit) end - def self.published(limit) - indexed.by_created_at.limit(limit) - end - - def self.find_from_slug!(rubygem_id, slug) - rubygem = rubygem_id.is_a?(Rubygem) ? rubygem_id : Rubygem.find(rubygem_id) - find_by!(full_name: "#{rubygem.name}-#{slug}") + def self.published + indexed.by_created_at end def self.rubygem_name_for(full_name) find_by(full_name: full_name)&.rubygem&.name end + # id is added to ORDER to return stable results for gems pushed at the same time def self.created_between(start_time, end_time) - where(created_at: start_time..end_time).order(:created_at) + where(created_at: start_time..end_time).order(:created_at, :id) end def platformed? @@ -178,6 +202,20 @@ def platformed? delegate :reorder_versions, to: :rubygem + def authored_at + return built_at if rely_on_built_at? + + created_at + end + + # Originally used to prevent showing misidentified dates for gems predating RubyGems, + # this method also covers cases where a Gem::Specification date is obviously invalid due to build-time considerations. + def rely_on_built_at? + return false if created_at.to_date != RUBYGEMS_IMPORT_DATE + + built_at && built_at <= RUBYGEMS_IMPORT_DATE + end + def refresh_rubygem_indexed rubygem.refresh_indexed! end @@ -194,6 +232,19 @@ def yanked? !indexed end + def cert_chain_valid_not_before + cert_chain.map(&:not_before).max + end + + def cert_chain_valid_not_after + cert_chain.map(&:not_after).min + end + + def signature_expired? + return false unless (expiration_time = cert_chain_valid_not_after) + expiration_time < Time.now.utc + end + def size self[:size] || "N/A" end @@ -220,8 +271,7 @@ def update_attributes_from_gem_specification!(spec) requirements: spec.requirements, built_at: spec.date, required_rubygems_version: spec.required_rubygems_version.to_s, - required_ruby_version: spec.required_ruby_version.to_s, - indexed: true + required_ruby_version: spec.required_ruby_version.to_s ) end @@ -268,13 +318,12 @@ def payload "prerelease" => prerelease, "licenses" => licenses, "requirements" => requirements, - "sha" => sha256_hex + "sha" => sha256_hex, + "spec_sha" => spec_sha256_hex } end - def as_json(*) - payload - end + delegate :as_json, :to_yaml, to: :payload def to_xml(options = {}) payload.to_xml(options.merge(root: "version")) @@ -292,11 +341,14 @@ def to_title end end - def to_bundler - if number[0] == "0" || prerelease? + def to_bundler(locked_version: false) + if prerelease? + modifier = locked_version ? "" : "~> " + %(gem '#{rubygem.name}', '#{modifier}#{number}') + elsif number[0] == "0" %(gem '#{rubygem.name}', '~> #{number}') else - release = feature_release(number) + release = feature_release if release == Gem::Version.new(number) %(gem '#{rubygem.name}', '~> #{release}') else @@ -314,7 +366,7 @@ def to_install latest = if prerelease rubygem.versions.by_position.prerelease.first else - rubygem.versions.most_recent + rubygem.most_recent_version end command << " -v #{number}" if latest != self command << " --pre" if prerelease @@ -329,6 +381,10 @@ def sha256_hex Version._sha256_hex(sha256) if sha256 end + def spec_sha256_hex + Version._sha256_hex(spec_sha256) if spec_sha256 + end + def self._sha256_hex(raw) raw.unpack1("m0").unpack1("H*") end @@ -337,8 +393,8 @@ def metadata_uri_set? Links::LINKS.any? { |_, long| metadata.key? long } end - def yanker - Deletion.find_by(rubygem: rubygem.name, number: number, platform: platform)&.user unless indexed + def rubygems_metadata_mfa_required? + ActiveRecord::Type::Boolean.new.cast(metadata["rubygems_mfa_required"]) end def prerelease @@ -346,6 +402,14 @@ def prerelease end alias prerelease? prerelease + def manifest + rubygem.version_manifest(number, platformed? ? platform : nil) + end + + def gem_file_name + "#{full_name}.gem" + end + private def update_prerelease @@ -357,6 +421,18 @@ def platform_and_number_are_unique errors.add(:base, "A version already exists with this number or platform.") end + def gem_platform_and_number_are_unique + platforms = Version.where(rubygem_id: rubygem_id, number: number, gem_platform: gem_platform).pluck(:platform) + return if platforms.empty? + errors.add(:base, "A version already exists with this number and resolved platform #{platforms}") + end + + def original_platform_resolves_to_gem_platform + resolved = Gem::Platform.new(platform).to_s + return if gem_platform == resolved + errors.add(:base, "The original platform #{platform} does not resolve the platform #{gem_platform} (instead it is #{resolved})") + end + def authors_format authors = authors_before_type_cast return unless authors @@ -371,15 +447,29 @@ def full_nameify! full_name << "-#{platform}" if platformed? end - def feature_release(number) - feature_version = Gem::Version.new(number).segments[0, 2].join(".") + def gem_full_nameify! + return if gem_platform.blank? + return if rubygem.nil? + self.gem_full_name = "#{rubygem.name}-#{number}" + gem_full_name << "-#{gem_platform}" unless gem_platform == "ruby" + end + + def set_canonical_number + return unless Gem::Version.correct?(number) + self.canonical_number = to_gem_version.canonical_segments.join(".") + end + + def feature_release + feature_version = to_gem_version.release.segments[0, 2].join(".") Gem::Version.new(feature_version) end def metadata_links_format Linkset::LINKS.each do |link| - errors.add(:metadata, "['#{link}'] does not appear to be a valid URL") if - metadata[link] && metadata[link] !~ Patterns::URL_VALIDATION_REGEXP + url = metadata[link] + next unless url + next if Patterns::URL_VALIDATION_REGEXP.match?(url) + errors.add(:metadata, "['#{link}'] does not appear to be a valid URL") end end @@ -399,4 +489,30 @@ def unique_canonical_number version = Version.find_by(canonical_number: canonical_number, rubygem_id: rubygem_id, platform: platform) errors.add(:canonical_number, "has already been taken. Existing version: #{version.number}") unless version.nil? end + + def no_dashes_in_version_number + return unless number&.include?("-") + errors.add(:number, "cannot contain a dash (it will be uninstallable)") + end + + def create_link_verifications + uris = metadata.values_at(*Links::LINKS.values).compact_blank.uniq + verifications = rubygem.link_verifications.where(uri: uris).index_by(&:uri) + uris.each do |uri| + verification = verifications.fetch(uri) { rubygem.link_verifications.create_or_find_by!(uri:) } + verification.retry_if_needed + end + end + + def record_push_event + rubygem.record_event!(Events::RubygemEvent::VERSION_PUSHED, number: number, platform: platform, sha256: sha256_hex, + pushed_by: pusher&.display_handle, version_gid: to_gid, actor_gid: pusher&.to_gid) + end + + def enqueue_web_hook_jobs + jobs = rubygem.web_hooks.or(WebHook.global).enabled + jobs.find_each do |job| + job.fire(Gemcutter::PROTOCOL, Gemcutter::HOST, self) + end + end end diff --git a/app/models/version_manifest.rb b/app/models/version_manifest.rb new file mode 100644 index 00000000000..479a11a5372 --- /dev/null +++ b/app/models/version_manifest.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +class VersionManifest + DEFAULT_DIGEST = "sha256" + + attr_reader :version, :contents + + delegate :gem, :fs, to: :contents + + def initialize(gem:, number:, platform: nil) + platform = nil if platform == "ruby" + @version = [number, platform.presence].compact.join("-") + raise ArgumentError, "version number-platform must be valid: #{@version.inspect}" unless @version.match?(Rubygem::NAME_PATTERN) + @contents = RubygemContents.new(gem: gem) + end + + ## + # Access stored content + + def paths + fs.each_key(prefix: path_root).map { |key| key.delete_prefix path_root } + end + + # @return [RubygemContents::Entry] + def entry(path) + path = path.to_s + return if path.blank? + response = fs.head path_key(path) + return if response.blank? || response[:metadata].blank? + RubygemContents::Entry.from_metadata(response[:metadata]) { |entry| content(entry.fingerprint) } + end + + def checksums_file + fs.get(checksums_key) + end + + def checksums + ShasumFormat.parse checksums_file + end + + def content(fingerprint) + contents.get fingerprint + end + + def spec + fs.get spec_key + end + + ## + # Store version content + + # @param [Gem::Package] package + def store_package(package) + entries = GemPackageEnumerator.new(package).filter_map do |tar_entry| + Rails.error.handle(context: { gem: package.spec.full_name, entry: tar_entry.full_name }) do + RubygemContents::Entry.from_tar_entry(tar_entry) + end + end + store_entries entries + store_spec package.spec + end + + # Writing version contents is done in one pass, collecting all the checksums + # and paths and writing them to the .sha256 checksums file at the end. + # All files in the gem must be enumerated so no checksums are missing + # from the .sha256 file stored at the end. + # @param [Enumerable] entries + def store_entries(entries) + path_checksums = {} + entries.each do |entry| + path_checksums[entry.path] = entry.sha256 if entry.sha256.present? + store_entry entry + end + store_checksums path_checksums + end + + # @param [RubygemContents::Entry] entry + def store_entry(entry) + store_path entry + contents.store entry if entry.body_persisted? + end + + # @param [RubygemContents::Entry] entry + def store_path(entry) + fs.store( + path_key(entry.path), + entry.fingerprint, + content_type: "text/plain; charset=us-ascii", + metadata: entry.metadata + ) + end + + # @param [Hash] checksums path => checksum + def store_checksums(checksums) + fs.store( + checksums_key, + ShasumFormat.generate(checksums), + content_type: "text/plain" + ) + end + + # @param [Gem::Specification] spec + def store_spec(spec) + ruby_spec = spec.to_ruby + mime = Magic.buffer(ruby_spec, Magic::MIME) + fs.store spec_key, ruby_spec, content_type: mime + end + + def yank + content_keys = unique_checksums.map { |checksum| contents.key checksum } + path_keys = fs.each_key(prefix: path_root).to_a + fs.remove(spec_key, path_keys, content_keys, checksums_key) + end + + def unique_checksums + candidates = checksums.values + candidates = contents.keys if candidates.blank? + return [] if candidates.empty? + + # starting with all candidates, remove checksums found in other versions + fs.each_key(prefix: checksums_root).reduce(candidates) do |remaining, key| + next remaining if key == checksums_key + checksums = ShasumFormat.parse(fs.get(key)).values + remaining.difference checksums + end + end + + def spec_key + format RubygemContents::SPEC_KEY_FORMAT, gem: gem, version: version + end + + def path_root + format RubygemContents::PATH_ROOT_FORMAT, gem: gem, version: version + end + + def path_key(path) + format RubygemContents::PATH_KEY_FORMAT, gem: gem, version: version, path: path + end + + def checksums_root + format RubygemContents::CHECKSUMS_ROOT_FORMAT, gem: gem + end + + def checksums_key(format: DEFAULT_DIGEST) + format RubygemContents::CHECKSUMS_KEY_FORMAT, gem: gem, version: version, format: + end + + def ==(other) + other.is_a?(self.class) && other.version == version && other.gem == gem + end +end diff --git a/app/models/web_hook.rb b/app/models/web_hook.rb index 4a938ad2528..4dca75e5368 100644 --- a/app/models/web_hook.rb +++ b/app/models/web_hook.rb @@ -1,34 +1,47 @@ class WebHook < ApplicationRecord GLOBAL_PATTERN = "*".freeze + TOO_MANY_FAILURES_DISABLED_REASON = "too many failures since the last success".freeze + FAILURE_DISABLE_THRESHOLD = 25 + FAILURE_DISABLE_DURATION = 1.month belongs_to :user belongs_to :rubygem, optional: true + has_many :audits, as: :auditable, dependent: nil + validates_formatting_of :url, using: :url, message: "does not appear to be a valid URL" validates :url, length: { maximum: Gemcutter::MAX_FIELD_LENGTH }, presence: true validate :unique_hook, on: :create - def self.global - where(rubygem_id: nil) - end + scope :global, -> { where(rubygem_id: nil) } - def self.specific - where.not(rubygem_id: nil) - end + scope :specific, -> { where.not(rubygem_id: nil) } + + scope :enabled, -> { where(disabled_at: nil) } + scope :disabled, -> { where.not(disabled_at: nil) } + + def fire(protocol, host_with_port, version, delayed: true) + job = NotifyWebHookJob.new(webhook: self, protocol:, host_with_port:, version:, poll_delivery: !delayed) - def fire(protocol, host_with_port, deploy_gem, version, delayed: true) - job = Notifier.new(url, protocol, host_with_port, deploy_gem, version, user.api_key) if delayed - Delayed::Job.enqueue job, priority: PRIORITIES[:web_hook] + job.enqueue else - job.perform + job.send(:_perform_job) end end + def api_key + user.api_key || user.api_keys.first&.hashed_key + end + def global? rubygem_id.blank? end + def enabled? + disabled_at.blank? + end + def success_message "Successfully created webhook for #{what} to #{url}" end @@ -60,24 +73,54 @@ def payload } end - def as_json(*) - payload - end + delegate :as_json, :to_yaml, to: :payload def to_xml(options = {}) payload.to_xml(options.merge(root: "web_hook")) end - def to_yaml(*args) - payload.to_yaml(*args) - end - def encode_with(coder) coder.tag = nil coder.implicit = true coder.map = payload end + def success!(completed_at:) + transaction do + if happened_after?(completed_at, last_failure) + increment :successes_since_last_failure + self.failures_since_last_success = 0 + end + self.last_success = completed_at if happened_after?(completed_at, last_success) + save! + end + end + + def failure!(completed_at:) + transaction do + increment :failure_count + if happened_after?(completed_at, last_success) + increment :failures_since_last_success + self.successes_since_last_failure = 0 + end + self.last_failure = completed_at if happened_after?(completed_at, last_failure) + save! + end + + return unless failures_since_last_success >= FAILURE_DISABLE_THRESHOLD && ((last_success.presence || created_at) < FAILURE_DISABLE_DURATION.ago) + disable!(TOO_MANY_FAILURES_DISABLED_REASON) + end + + def disable!(disabled_reason) + transaction do + next if disabled_at.present? + update!(disabled_reason:) + touch(:disabled_at) + + WebHooksMailer.webhook_disabled(self).deliver_later + end + end + private def unique_hook @@ -97,4 +140,10 @@ def unique_hook errors.add(:base, "A user is required for this hook") end end + + def happened_after?(event, reference) + return true if reference.nil? + + event.after?(reference) + end end diff --git a/app/models/webauthn_credential.rb b/app/models/webauthn_credential.rb new file mode 100644 index 00000000000..99ca6b3ce34 --- /dev/null +++ b/app/models/webauthn_credential.rb @@ -0,0 +1,36 @@ +class WebauthnCredential < ApplicationRecord + belongs_to :user + + validates :external_id, uniqueness: true, presence: true, length: { maximum: 512 } + validates :public_key, presence: true, length: { maximum: 512 } + validates :nickname, presence: true, uniqueness: { scope: :user_id }, length: { maximum: 64 } + validates :sign_count, presence: true, numericality: { greater_than_or_equal_to: 0 } + + after_create :send_creation_email + after_create :enable_user_mfa + after_destroy :send_deletion_email + after_destroy :disable_user_mfa + + private + + def send_creation_email + Mailer.webauthn_credential_created(id).deliver_later + end + + def send_deletion_email + Mailer.webauthn_credential_removed(user_id, nickname, Time.now.utc).deliver_later + end + + def enable_user_mfa + user.mfa_method_added(:ui_and_api) + user.save!(validate: false) + end + + def disable_user_mfa + return unless user.no_mfa_devices? + user.mfa_level = :disabled + user.new_mfa_recovery_codes = nil + user.mfa_hashed_recovery_codes = [] + user.save!(validate: false) + end +end diff --git a/app/models/webauthn_verification.rb b/app/models/webauthn_verification.rb new file mode 100644 index 00000000000..95b19ae1485 --- /dev/null +++ b/app/models/webauthn_verification.rb @@ -0,0 +1,39 @@ +class WebauthnVerification < ApplicationRecord + belongs_to :user + + validates :user_id, uniqueness: true + validates :path_token, presence: true, uniqueness: true, length: { maximum: 128 } + validates :path_token_expires_at, presence: true + + def expire_path_token + self.path_token_expires_at = 1.second.ago + save! + end + + def path_token_expired? + path_token_expires_at < Time.now.utc + end + + def generate_otp + self.otp = SecureRandom.base58(16) + self.otp_expires_at = 2.minutes.from_now + save! + end + + def verify_otp(otp) + return false if self.otp.nil? || otp != self.otp || otp_expired? + expire_otp + end + + def otp_expired? + return false if otp_expires_at.nil? + otp_expires_at < Time.now.utc + end + + private + + def expire_otp + self.otp_expires_at = 1.second.ago + save! + end +end diff --git a/app/policies/admin/api_key_policy.rb b/app/policies/admin/api_key_policy.rb new file mode 100644 index 00000000000..d8d4499fc67 --- /dev/null +++ b/app/policies/admin/api_key_policy.rb @@ -0,0 +1,15 @@ +class Admin::ApiKeyPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + has_association :api_key_rubygem_scope + has_association :ownership + has_association :oidc_id_token + + def avo_show? + policy!(user, record.owner).avo_show? + end +end diff --git a/app/policies/admin/api_key_rubygem_scope_policy.rb b/app/policies/admin/api_key_rubygem_scope_policy.rb new file mode 100644 index 00000000000..3bb6b6ebdf0 --- /dev/null +++ b/app/policies/admin/api_key_rubygem_scope_policy.rb @@ -0,0 +1,11 @@ +class Admin::ApiKeyRubygemScopePolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + def avo_show? + policy!(user, record.ownership).avo_show? + end +end diff --git a/app/policies/admin/application_policy.rb b/app/policies/admin/application_policy.rb new file mode 100644 index 00000000000..38e5bb0f862 --- /dev/null +++ b/app/policies/admin/application_policy.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +class Admin::ApplicationPolicy + include Admin::Concerns::PolicyHelpers + include SemanticLogger::Loggable + + attr_reader :user, :record + + def initialize(user, record) + @user = user + @record = record + end + + def avo_index? + false + end + + def avo_show? + false + end + + def avo_create? + false + end + + def avo_new? + avo_create? + end + + def avo_update? + false + end + + def avo_edit? + avo_update? + end + + def avo_destroy? + false + end + + def avo_search? + avo_index? + end + + def act_on? + false + end + + def self.has_association(assocation) # rubocop:disable Naming/PredicateName + %w[create attach detach destroy edit].each do |action| + define_method(:"#{action}_#{assocation}?") { false } + end + define_method(:"show_#{assocation}?") { policy!(user, record).avo_show? } + # can't use `alias_method` in case `has_asssociation` is called before `avo_show?` is defined + define_method(:"view_#{assocation}?") { avo_show? } + end + + class Scope + include Admin::Concerns::PolicyHelpers + + def initialize(user, scope) + @user = user + @scope = scope + end + + def resolve + raise NotImplementedError, "You must define #resolve in #{self.class}" + end + + private + + attr_reader :user, :scope + end +end diff --git a/app/policies/admin/audit_policy.rb b/app/policies/admin/audit_policy.rb new file mode 100644 index 00000000000..db45584e4c9 --- /dev/null +++ b/app/policies/admin/audit_policy.rb @@ -0,0 +1,20 @@ +class Admin::AuditPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + # NOTE: Be explicit about which records you allow access to! + def resolve + if rubygems_org_admin? + scope.all + else + scope.where(admin_github_user: current_user) + end + end + end + + def avo_index? + true + end + + def avo_show? + true + end +end diff --git a/app/policies/admin/concerns/policy_helpers.rb b/app/policies/admin/concerns/policy_helpers.rb new file mode 100644 index 00000000000..362afda6584 --- /dev/null +++ b/app/policies/admin/concerns/policy_helpers.rb @@ -0,0 +1,21 @@ +module Admin::Concerns::PolicyHelpers + extend ActiveSupport::Concern + + included do + def admin? + user.is_a?(Admin::GitHubUser) && user.is_admin + end + + def belongs_to_team?(slug) + admin? && user.team_member?(slug) + end + + def rubygems_org_admin? + belongs_to_team?("rubygems-org") + end + + def policy!(user, record) + Pundit.policy!(user, [:admin, record]) + end + end +end diff --git a/app/policies/admin/deletion_policy.rb b/app/policies/admin/deletion_policy.rb new file mode 100644 index 00000000000..1e86916fd1b --- /dev/null +++ b/app/policies/admin/deletion_policy.rb @@ -0,0 +1,17 @@ +class Admin::DeletionPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + has_association :version + + def avo_index? + rubygems_org_admin? + end + + def avo_show? + rubygems_org_admin? + end +end diff --git a/app/policies/admin/dependency_policy.rb b/app/policies/admin/dependency_policy.rb new file mode 100644 index 00000000000..ad92f6b0f80 --- /dev/null +++ b/app/policies/admin/dependency_policy.rb @@ -0,0 +1,11 @@ +class Admin::DependencyPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + def avo_show? + rubygems_org_admin? + end +end diff --git a/app/policies/admin/events/organization_event_policy.rb b/app/policies/admin/events/organization_event_policy.rb new file mode 100644 index 00000000000..558bdd4694a --- /dev/null +++ b/app/policies/admin/events/organization_event_policy.rb @@ -0,0 +1,13 @@ +class Admin::Events::OrganizationEventPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + has_association :organization + has_association :ip_address + + def avo_index? = rubygems_org_admin? + def avo_show? = rubygems_org_admin? +end diff --git a/app/policies/admin/events/rubygem_event_policy.rb b/app/policies/admin/events/rubygem_event_policy.rb new file mode 100644 index 00000000000..6f8f45c4a53 --- /dev/null +++ b/app/policies/admin/events/rubygem_event_policy.rb @@ -0,0 +1,13 @@ +class Admin::Events::RubygemEventPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + has_association :rubygem + has_association :ip_address + + def avo_index? = rubygems_org_admin? + def avo_show? = rubygems_org_admin? +end diff --git a/app/policies/admin/events/user_event_policy.rb b/app/policies/admin/events/user_event_policy.rb new file mode 100644 index 00000000000..d2d3b02f9d2 --- /dev/null +++ b/app/policies/admin/events/user_event_policy.rb @@ -0,0 +1,13 @@ +class Admin::Events::UserEventPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + has_association :user + has_association :ip_address + + def avo_index? = rubygems_org_admin? + def avo_show? = rubygems_org_admin? +end diff --git a/app/policies/admin/gem_download_policy.rb b/app/policies/admin/gem_download_policy.rb new file mode 100644 index 00000000000..ec2322e286c --- /dev/null +++ b/app/policies/admin/gem_download_policy.rb @@ -0,0 +1,16 @@ +class Admin::GemDownloadPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + # NOTE: Be explicit about which records you allow access to! + def resolve + scope.all + end + end + + def avo_index? + true + end + + def avo_show? + true + end +end diff --git a/app/policies/admin/gem_name_reservation_policy.rb b/app/policies/admin/gem_name_reservation_policy.rb new file mode 100644 index 00000000000..c539d9b0c58 --- /dev/null +++ b/app/policies/admin/gem_name_reservation_policy.rb @@ -0,0 +1,27 @@ +class Admin::GemNameReservationPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + def avo_index? + true + end + + def avo_show? + true + end + + def avo_create? + rubygems_org_admin? + end + + def avo_destroy? + rubygems_org_admin? + end + + def avo_search? + true + end +end diff --git a/app/policies/admin/gem_typo_exception_policy.rb b/app/policies/admin/gem_typo_exception_policy.rb new file mode 100644 index 00000000000..f6056ef61b5 --- /dev/null +++ b/app/policies/admin/gem_typo_exception_policy.rb @@ -0,0 +1,20 @@ +class Admin::GemTypoExceptionPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + def avo_index? + rubygems_org_admin? + end + + def avo_show? + rubygems_org_admin? + end + + def avo_create? = rubygems_org_admin? + def avo_update? = rubygems_org_admin? + def avo_destroy? = rubygems_org_admin? + def act_on? = rubygems_org_admin? +end diff --git a/app/policies/admin/geoip_info_policy.rb b/app/policies/admin/geoip_info_policy.rb new file mode 100644 index 00000000000..d60f1b1ffee --- /dev/null +++ b/app/policies/admin/geoip_info_policy.rb @@ -0,0 +1,12 @@ +class Admin::GeoipInfoPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + has_association :ip_addresses + + def avo_index? = rubygems_org_admin? + def avo_show? = rubygems_org_admin? +end diff --git a/app/policies/admin/github_user_policy.rb b/app/policies/admin/github_user_policy.rb new file mode 100644 index 00000000000..91eaabfb6c1 --- /dev/null +++ b/app/policies/admin/github_user_policy.rb @@ -0,0 +1,22 @@ +class Admin::GitHubUserPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + # NOTE: Be explicit about which records you allow access to! + def resolve + if rubygems_org_admin? + scope.all + else + scope.where(id: user.id) + end + end + end + + def avo_index? + rubygems_org_admin? + end + + def avo_show? + rubygems_org_admin? + end + + has_association :audits +end diff --git a/app/policies/admin/ip_address_policy.rb b/app/policies/admin/ip_address_policy.rb new file mode 100644 index 00000000000..2f3fca42a56 --- /dev/null +++ b/app/policies/admin/ip_address_policy.rb @@ -0,0 +1,14 @@ +class Admin::IpAddressPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + has_association :user_events + has_association :rubygem_events + has_association :organization_events + + def avo_index? = rubygems_org_admin? + def avo_show? = rubygems_org_admin? +end diff --git a/app/policies/admin/link_verification_policy.rb b/app/policies/admin/link_verification_policy.rb new file mode 100644 index 00000000000..ff8f2f812cb --- /dev/null +++ b/app/policies/admin/link_verification_policy.rb @@ -0,0 +1,10 @@ +class Admin::LinkVerificationPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + def avo_index? = rubygems_org_admin? + def avo_show? = rubygems_org_admin? +end diff --git a/app/policies/admin/linkset_policy.rb b/app/policies/admin/linkset_policy.rb new file mode 100644 index 00000000000..ff2c35e2280 --- /dev/null +++ b/app/policies/admin/linkset_policy.rb @@ -0,0 +1,15 @@ +class Admin::LinksetPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + def avo_index? + policy!(user, Rubygem).avo_index? + end + + def avo_show? + policy!(user, record.rubygem).avo_show? + end +end diff --git a/app/policies/admin/log_ticket_policy.rb b/app/policies/admin/log_ticket_policy.rb new file mode 100644 index 00000000000..1c9517c9e36 --- /dev/null +++ b/app/policies/admin/log_ticket_policy.rb @@ -0,0 +1,15 @@ +class Admin::LogTicketPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + def avo_index? + rubygems_org_admin? + end + + def avo_show? + rubygems_org_admin? + end +end diff --git a/app/policies/admin/maintenance_tasks/run_policy.rb b/app/policies/admin/maintenance_tasks/run_policy.rb new file mode 100644 index 00000000000..dab68f1715f --- /dev/null +++ b/app/policies/admin/maintenance_tasks/run_policy.rb @@ -0,0 +1,15 @@ +class Admin::MaintenanceTasks::RunPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + def avo_index? + rubygems_org_admin? + end + + def avo_show? + rubygems_org_admin? + end +end diff --git a/app/policies/admin/membership_policy.rb b/app/policies/admin/membership_policy.rb new file mode 100644 index 00000000000..eb22ee86781 --- /dev/null +++ b/app/policies/admin/membership_policy.rb @@ -0,0 +1,11 @@ +class Admin::MembershipPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + def avo_show? + rubygems_org_admin? + end +end diff --git a/app/policies/admin/oidc/api_key_role_policy.rb b/app/policies/admin/oidc/api_key_role_policy.rb new file mode 100644 index 00000000000..51f0c3ae0c5 --- /dev/null +++ b/app/policies/admin/oidc/api_key_role_policy.rb @@ -0,0 +1,16 @@ +class Admin::OIDC::ApiKeyRolePolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + has_association :provider + has_association :id_tokens + + def avo_index? = rubygems_org_admin? + def avo_show? = rubygems_org_admin? + def avo_create? = rubygems_org_admin? + def avo_update? = rubygems_org_admin? + def act_on? = rubygems_org_admin? +end diff --git a/app/policies/admin/oidc/id_token_policy.rb b/app/policies/admin/oidc/id_token_policy.rb new file mode 100644 index 00000000000..723b0812567 --- /dev/null +++ b/app/policies/admin/oidc/id_token_policy.rb @@ -0,0 +1,14 @@ +class Admin::OIDC::IdTokenPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + has_association :provider + has_association :api_key_role + has_association :api_key + + def avo_index? = rubygems_org_admin? + def avo_show? = rubygems_org_admin? +end diff --git a/app/policies/admin/oidc/pending_trusted_publisher_policy.rb b/app/policies/admin/oidc/pending_trusted_publisher_policy.rb new file mode 100644 index 00000000000..452917695d0 --- /dev/null +++ b/app/policies/admin/oidc/pending_trusted_publisher_policy.rb @@ -0,0 +1,13 @@ +class Admin::OIDC::PendingTrustedPublisherPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + has_association :rubygem + has_association :trusted_publisher + + def avo_index? = rubygems_org_admin? + def avo_show? = rubygems_org_admin? +end diff --git a/app/policies/admin/oidc/provider_policy.rb b/app/policies/admin/oidc/provider_policy.rb new file mode 100644 index 00000000000..2ce7284a943 --- /dev/null +++ b/app/policies/admin/oidc/provider_policy.rb @@ -0,0 +1,15 @@ +class Admin::OIDC::ProviderPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + has_association :api_key_roles + + def avo_index? = rubygems_org_admin? + def avo_show? = rubygems_org_admin? + def avo_create? = rubygems_org_admin? + def avo_update? = rubygems_org_admin? + def act_on? = rubygems_org_admin? +end diff --git a/app/policies/admin/oidc/rubygem_trusted_publisher_policy.rb b/app/policies/admin/oidc/rubygem_trusted_publisher_policy.rb new file mode 100644 index 00000000000..eb8877afe0b --- /dev/null +++ b/app/policies/admin/oidc/rubygem_trusted_publisher_policy.rb @@ -0,0 +1,13 @@ +class Admin::OIDC::RubygemTrustedPublisherPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + has_association :rubygem + has_association :trusted_publisher + + def avo_index? = rubygems_org_admin? + def avo_show? = rubygems_org_admin? +end diff --git a/app/policies/admin/oidc/trusted_publisher/github_action_policy.rb b/app/policies/admin/oidc/trusted_publisher/github_action_policy.rb new file mode 100644 index 00000000000..191795b52ec --- /dev/null +++ b/app/policies/admin/oidc/trusted_publisher/github_action_policy.rb @@ -0,0 +1,16 @@ +class Admin::OIDC::TrustedPublisher::GitHubActionPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + has_association :trusted_publishers + has_association :rubygem_trusted_publishers + has_association :pending_trusted_publishers + has_association :rubygems + has_association :api_keys + + def avo_index? = rubygems_org_admin? + def avo_show? = rubygems_org_admin? +end diff --git a/app/policies/admin/organization_policy.rb b/app/policies/admin/organization_policy.rb new file mode 100644 index 00000000000..12291ad110a --- /dev/null +++ b/app/policies/admin/organization_policy.rb @@ -0,0 +1,22 @@ +class Admin::OrganizationPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + has_association :memberships + has_association :users + + def avo_index? + rubygems_org_admin? + end + + def avo_show? + rubygems_org_admin? + end + + def act_on? + rubygems_org_admin? + end +end diff --git a/app/policies/admin/ownership_policy.rb b/app/policies/admin/ownership_policy.rb new file mode 100644 index 00000000000..fb446f62cf6 --- /dev/null +++ b/app/policies/admin/ownership_policy.rb @@ -0,0 +1,13 @@ +class Admin::OwnershipPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + has_association :api_key_rubygem_scopes + + def avo_show? + rubygems_org_admin? + end +end diff --git a/app/policies/admin/rubygem_policy.rb b/app/policies/admin/rubygem_policy.rb new file mode 100644 index 00000000000..620b3614320 --- /dev/null +++ b/app/policies/admin/rubygem_policy.rb @@ -0,0 +1,39 @@ +class Admin::RubygemPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + if rubygems_org_admin? + scope.all + else + scope.with_versions + end + end + end + + has_association :versions + has_association :latest_version + has_association :ownerships + has_association :ownerships_including_unconfirmed + has_association :ownership_calls + has_association :ownership_requests + has_association :subscriptions + has_association :subscribers + has_association :web_hooks + has_association :linkset + has_association :gem_download + has_association :events + has_association :audits + has_association :link_verifications + has_association :oidc_rubygem_trusted_publishers + + def avo_index? + rubygems_org_admin? + end + + def avo_show? + rubygems_org_admin? + end + + def act_on? + rubygems_org_admin? + end +end diff --git a/app/policies/admin/sendgrid_event_policy.rb b/app/policies/admin/sendgrid_event_policy.rb new file mode 100644 index 00000000000..2a9d008723f --- /dev/null +++ b/app/policies/admin/sendgrid_event_policy.rb @@ -0,0 +1,15 @@ +class Admin::SendgridEventPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + def avo_index? + rubygems_org_admin? + end + + def avo_show? + rubygems_org_admin? + end +end diff --git a/app/policies/admin/user_policy.rb b/app/policies/admin/user_policy.rb new file mode 100644 index 00000000000..117f703d5d8 --- /dev/null +++ b/app/policies/admin/user_policy.rb @@ -0,0 +1,39 @@ +class Admin::UserPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + # NOTE: Be explicit about which records you allow access to! + def resolve + scope.all + end + end + + has_association :api_keys + has_association :audits + has_association :deletions + has_association :events + has_association :memberships + has_association :oidc_api_key_roles + has_association :organizations + has_association :ownership_calls + has_association :ownership_requests + has_association :ownerships + has_association :pushed_versions + has_association :rubygems + has_association :subscribed_gems + has_association :subscriptions + has_association :unconfirmed_ownerships + has_association :web_hooks + has_association :webauthn_credentials + has_association :webauthn_verification + + def avo_index? + rubygems_org_admin? + end + + def avo_show? + rubygems_org_admin? + end + + def act_on? + rubygems_org_admin? + end +end diff --git a/app/policies/admin/version_policy.rb b/app/policies/admin/version_policy.rb new file mode 100644 index 00000000000..50670f2ab86 --- /dev/null +++ b/app/policies/admin/version_policy.rb @@ -0,0 +1,27 @@ +class Admin::VersionPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + if rubygems_org_admin? + scope.all + else + scope.indexed + end + end + end + + has_association :dependencies + has_association :gem_download + has_association :deletion + + def avo_index? + rubygems_org_admin? + end + + def avo_show? + rubygems_org_admin? + end + + def act_on? + rubygems_org_admin? + end +end diff --git a/app/policies/admin/web_hook_policy.rb b/app/policies/admin/web_hook_policy.rb new file mode 100644 index 00000000000..b3ccb7a87b5 --- /dev/null +++ b/app/policies/admin/web_hook_policy.rb @@ -0,0 +1,21 @@ +class Admin::WebHookPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + has_association :audits + + def avo_index? + rubygems_org_admin? + end + + def avo_show? + rubygems_org_admin? + end + + def act_on? + rubygems_org_admin? + end +end diff --git a/app/policies/admin/webauthn_credential_policy.rb b/app/policies/admin/webauthn_credential_policy.rb new file mode 100644 index 00000000000..9bcd1c888d1 --- /dev/null +++ b/app/policies/admin/webauthn_credential_policy.rb @@ -0,0 +1,13 @@ +class Admin::WebauthnCredentialPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + has_association :user + + def avo_show? + policy!(user, record.user).avo_show? + end +end diff --git a/app/policies/admin/webauthn_verification_policy.rb b/app/policies/admin/webauthn_verification_policy.rb new file mode 100644 index 00000000000..9b246cb283f --- /dev/null +++ b/app/policies/admin/webauthn_verification_policy.rb @@ -0,0 +1,13 @@ +class Admin::WebauthnVerificationPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + has_association :user + + def avo_show? + policy!(user, record.user).avo_show? + end +end diff --git a/app/policies/api/application_policy.rb b/app/policies/api/application_policy.rb new file mode 100644 index 00000000000..3d7d9b7f873 --- /dev/null +++ b/app/policies/api/application_policy.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +class Api::ApplicationPolicy + class Scope + def initialize(api_key, scope) + @api_key = api_key + @scope = scope + end + + def resolve + raise NotImplementedError, "You must define #resolve in #{self.class}" + end + + private + + attr_reader :api_key, :scope + end + + attr_reader :user, :record, :error, :api_key + + def initialize(api_key, record) + @user = api_key.user + @record = record + @error = nil + @api_key = api_key + end + + def index? = false + def show? = false + def create? = false + def new? = create? + def update? = false + def edit? = update? + def destroy? = false + + private + + delegate :t, to: I18n + + def deny(error) + @error = error + false + end + + def api_policy!(record) + Pundit.policy!(api_key, [:api, record]) + end + + def user_policy!(record) + Pundit.policy!(api_key.user, record) + end + + def api_authorized?(record, action) + policy = api_policy!(record) + policy.send(action) || deny(policy.error) + end + + def user_authorized?(record, action) + policy = user_policy!(record) + policy.send(action) || deny(policy.error) + end + + def api_key_scope?(scope, rubygem = nil) + api_key.scope?(scope, rubygem) || deny(t(:api_key_insufficient_scope)) + end + + def mfa_requirement_satisfied?(rubygem = nil) + if rubygem && !rubygem.mfa_requirement_satisfied_for?(user) + deny t("multifactor_auths.api.mfa_required") + elsif user&.mfa_required_not_yet_enabled? + deny t("multifactor_auths.api.mfa_required_not_yet_enabled").chomp + elsif user&.mfa_required_weak_level_enabled? + deny t("multifactor_auths.api.mfa_required_weak_level_enabled").chomp + else + true + end + end + + def user_api_key? + return true if user + deny t(:api_key_forbidden) + end +end diff --git a/app/policies/api/nil_class_policy.rb b/app/policies/api/nil_class_policy.rb new file mode 100644 index 00000000000..820bb9d6e89 --- /dev/null +++ b/app/policies/api/nil_class_policy.rb @@ -0,0 +1,11 @@ +class Api::NilClassPolicy < ApplicationPolicy + class Scope < ApplicationPolicy::Scope + def resolve + raise Pundit::NotDefinedError, "Cannot scope NilClass" + end + end + + def destroy? + false + end +end diff --git a/app/policies/api/ownership_policy.rb b/app/policies/api/ownership_policy.rb new file mode 100644 index 00000000000..464b2376bf0 --- /dev/null +++ b/app/policies/api/ownership_policy.rb @@ -0,0 +1,11 @@ +class Api::OwnershipPolicy < Api::ApplicationPolicy + class Scope < Api::ApplicationPolicy::Scope + end + + delegate :rubygem, to: :record + + def update? + api_authorized?(rubygem, :update_owner?) && + user_authorized?(record, :update?) + end +end diff --git a/app/policies/api/rubygem_policy.rb b/app/policies/api/rubygem_policy.rb new file mode 100644 index 00000000000..690ede046c3 --- /dev/null +++ b/app/policies/api/rubygem_policy.rb @@ -0,0 +1,49 @@ +class Api::RubygemPolicy < Api::ApplicationPolicy + class Scope < Api::ApplicationPolicy::Scope + end + + alias rubygem record + + def index? + api_key_scope?(:index_rubygems) + end + + def create? + mfa_requirement_satisfied? && + api_key_scope?(:push_rubygem) + end + + def yank? + user_api_key? && + mfa_requirement_satisfied?(rubygem) && + api_key_scope?(:yank_rubygem, rubygem) + end + + def add_owner? + user_api_key? && + mfa_requirement_satisfied?(rubygem) && + api_key_scope?(:add_owner, rubygem) && + user_authorized?(rubygem, :add_owner?) + end + + def update_owner? + user_api_key? && + mfa_requirement_satisfied?(rubygem) && + api_key_scope?(:update_owner, rubygem) && + user_authorized?(rubygem, :update_owner?) + end + + def remove_owner? + user_api_key? && + mfa_requirement_satisfied?(rubygem) && + api_key_scope?(:remove_owner, rubygem) && + user_authorized?(rubygem, :remove_owner?) + end + + def configure_trusted_publishers? + user_api_key? && + mfa_requirement_satisfied?(rubygem) && + api_key_scope?(:configure_trusted_publishers, rubygem) && + user_authorized?(rubygem, :configure_trusted_publishers?) + end +end diff --git a/app/policies/api/web_hook_policy.rb b/app/policies/api/web_hook_policy.rb new file mode 100644 index 00000000000..51f8ea73473 --- /dev/null +++ b/app/policies/api/web_hook_policy.rb @@ -0,0 +1,28 @@ +class Api::WebHookPolicy < Api::ApplicationPolicy + class Scope < Api::ApplicationPolicy::Scope + end + + delegate :rubygem, to: :record + + def index? + can_access_webhooks? + end + + def create? + can_access_webhooks?(rubygem) + end + + def fire? + can_access_webhooks?(rubygem) + end + + def remove? + can_access_webhooks?(rubygem) + end + + private + + def can_access_webhooks?(rubygem = nil) + api_key_scope?(:access_webhooks, rubygem) + end +end diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb new file mode 100644 index 00000000000..e999c1fac18 --- /dev/null +++ b/app/policies/application_policy.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +class ApplicationPolicy + include SemanticLogger::Loggable + + class Scope + def initialize(user, scope) + @user = user + @scope = scope + end + + def resolve + raise NotImplementedError, "You must define #resolve in #{self.class}" + end + + private + + attr_reader :user, :scope + end + + attr_reader :user, :record, :error + + def initialize(user, record) + @user = user + @record = record + @error = nil + end + + def index? + false + end + + def show? + false + end + + def create? + false + end + + def new? + create? + end + + def update? + false + end + + def edit? + update? + end + + def destroy? + false + end + + def search? + index? + end + + private + + delegate :t, to: I18n + + def deny(error = t(:forbidden)) + @error = error + false + end + + def allow + @error = nil + true + end + + def current_user?(record_user) + user && user == record_user + end + + def rubygem_owned_by?(user) + rubygem.owned_by?(user) || deny(t(:forbidden)) + end + + def rubygem_owned_by_with_role?(user, minimum_required_role:) + rubygem.owned_by_with_role?(user, minimum_required_role) || deny(t(:forbidden)) + end + + def policy!(user, record) = Pundit.policy!(user, record) + def user_policy!(record) = policy!(user, record) + + def user_authorized?(record, action) + policy = user_policy!(record) + policy.send(action) || deny(policy.error) + end +end diff --git a/app/policies/events/rubygem_event_policy.rb b/app/policies/events/rubygem_event_policy.rb new file mode 100644 index 00000000000..3b6580ae817 --- /dev/null +++ b/app/policies/events/rubygem_event_policy.rb @@ -0,0 +1,22 @@ +class Events::RubygemEventPolicy < ApplicationPolicy + class Scope < ApplicationPolicy::Scope + end + + delegate :rubygem, to: :record + + def show? + rubygem.owned_by?(user) + end + + def create? + false + end + + def update? + false + end + + def destroy? + false + end +end diff --git a/app/policies/oidc/pending_trusted_publisher_policy.rb b/app/policies/oidc/pending_trusted_publisher_policy.rb new file mode 100644 index 00000000000..2a5cf862f98 --- /dev/null +++ b/app/policies/oidc/pending_trusted_publisher_policy.rb @@ -0,0 +1,19 @@ +class OIDC::PendingTrustedPublisherPolicy < ApplicationPolicy + class Scope < ApplicationPolicy::Scope + def resolve + scope.where(user: user) + end + end + + def show? + current_user?(record.user) + end + + def create? + current_user?(record.user) + end + + def destroy? + current_user?(record.user) + end +end diff --git a/app/policies/oidc/rubygem_trusted_publisher_policy.rb b/app/policies/oidc/rubygem_trusted_publisher_policy.rb new file mode 100644 index 00000000000..85ea19aef2d --- /dev/null +++ b/app/policies/oidc/rubygem_trusted_publisher_policy.rb @@ -0,0 +1,18 @@ +class OIDC::RubygemTrustedPublisherPolicy < ApplicationPolicy + class Scope < ApplicationPolicy::Scope + end + + delegate :rubygem, to: :record + + def show? + rubygem_owned_by_with_role?(user, minimum_required_role: :owner) + end + + def create? + rubygem_owned_by_with_role?(user, minimum_required_role: :owner) + end + + def destroy? + rubygem_owned_by_with_role?(user, minimum_required_role: :owner) + end +end diff --git a/app/policies/organization_policy.rb b/app/policies/organization_policy.rb new file mode 100644 index 00000000000..249750d60bd --- /dev/null +++ b/app/policies/organization_policy.rb @@ -0,0 +1,54 @@ +class OrganizationPolicy < ApplicationPolicy + class Scope < ApplicationPolicy::Scope + end + + def show? + true + end + + def update? + organization_member_with_role?(user, minimum_required_role: Access::OWNER) + end + + def create? + true + end + + def add_gem? + organization_member_with_role?(user, minimum_required_role: Access::OWNER) + end + + def remove_gem? + organization_member_with_role?(user, minimum_required_role: Access::OWNER) + end + + def add_membership? + organization_member_with_role?(user, minimum_required_role: Access::OWNER) + end + + def update_membership? + organization_member_with_role?(user, minimum_required_role: Access::OWNER) + end + + def remove_membership? + organization_member_with_role?(user, minimum_required_role: Access::OWNER) + end + + def show_membership? + organization_member_with_role?(user, minimum_required_role: Access::MAINTAINER) + end + + def list_memberships? + organization_member_with_role?(user, minimum_required_role: Access::MAINTAINER) + end + + def destroy? + false # For now organizations cannot be deleted + end + + private + + def organization_member_with_role?(user, minimum_required_role:) + record.memberships.exists?(["user_id = ? AND role >= ?", user.id, minimum_required_role]) + end +end diff --git a/app/policies/ownership_call_policy.rb b/app/policies/ownership_call_policy.rb new file mode 100644 index 00000000000..dafeff928cc --- /dev/null +++ b/app/policies/ownership_call_policy.rb @@ -0,0 +1,14 @@ +class OwnershipCallPolicy < ApplicationPolicy + class Scope < ApplicationPolicy::Scope + end + + delegate :rubygem, to: :record + + def create? + user_authorized?(rubygem, :manage_adoption?) + end + + def close? + user_authorized?(rubygem, :manage_adoption?) + end +end diff --git a/app/policies/ownership_policy.rb b/app/policies/ownership_policy.rb new file mode 100644 index 00000000000..ad31e15c265 --- /dev/null +++ b/app/policies/ownership_policy.rb @@ -0,0 +1,20 @@ +class OwnershipPolicy < ApplicationPolicy + class Scope < ApplicationPolicy::Scope + end + + delegate :rubygem, to: :record + + def create? + policy!(user, rubygem).add_owner? + end + + def update? + return deny(t("owners.update.update_current_user_role")) if current_user?(record.user) + policy!(user, rubygem).update_owner? + end + alias edit? update? + + def destroy? + policy!(user, rubygem).remove_owner? + end +end diff --git a/app/policies/ownership_request_policy.rb b/app/policies/ownership_request_policy.rb new file mode 100644 index 00000000000..c1db2e38200 --- /dev/null +++ b/app/policies/ownership_request_policy.rb @@ -0,0 +1,18 @@ +class OwnershipRequestPolicy < ApplicationPolicy + class Scope < ApplicationPolicy::Scope + end + + delegate :rubygem, to: :record + + def create? + current_user?(record.user) && user_authorized?(rubygem, :request_ownership?) + end + + def approve? + rubygem_owned_by?(user) + end + + def close? + current_user?(record.user) || rubygem_owned_by?(user) + end +end diff --git a/app/policies/rubygem_policy.rb b/app/policies/rubygem_policy.rb new file mode 100644 index 00000000000..1c90dc77967 --- /dev/null +++ b/app/policies/rubygem_policy.rb @@ -0,0 +1,72 @@ +class RubygemPolicy < ApplicationPolicy + class Scope < ApplicationPolicy::Scope + end + + ABANDONED_RELEASE_AGE = 1.year + ABANDONED_DOWNLOADS_MAX = 10_000 + + alias rubygem record + + def show? + true + end + + def create? + user.present? + end + + def update? + false + end + + def destroy? + false + end + + def configure_oidc? + rubygem_owned_by_with_role?(user, minimum_required_role: :owner) + end + + def configure_trusted_publishers? + rubygem_owned_by_with_role?(user, minimum_required_role: :owner) + end + + def manage_adoption? + rubygem_owned_by_with_role?(user, minimum_required_role: :owner) + end + + def request_ownership? + return allow if rubygem.ownership_calls.any? + return false if rubygem.downloads >= ABANDONED_DOWNLOADS_MAX + return false if rubygem.latest_version.nil? || rubygem.latest_version.created_at.after?(ABANDONED_RELEASE_AGE.ago) + allow + end + + def show_adoption? + manage_adoption? || request_ownership? + end + + def show_events? + rubygem_owned_by?(user) + end + + def close_ownership_requests? + rubygem_owned_by_with_role?(user, minimum_required_role: :owner) + end + + def show_unconfirmed_ownerships? + rubygem_owned_by_with_role?(user, minimum_required_role: :owner) + end + + def add_owner? + rubygem_owned_by_with_role?(user, minimum_required_role: :owner) + end + + def update_owner? + rubygem_owned_by_with_role?(user, minimum_required_role: :owner) + end + + def remove_owner? + rubygem_owned_by_with_role?(user, minimum_required_role: :owner) + end +end diff --git a/app/tasks/maintenance/backfill_gem_platform_to_versions_task.rb b/app/tasks/maintenance/backfill_gem_platform_to_versions_task.rb new file mode 100644 index 00000000000..94bb970beed --- /dev/null +++ b/app/tasks/maintenance/backfill_gem_platform_to_versions_task.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class Maintenance::BackfillGemPlatformToVersionsTask < MaintenanceTasks::Task + include SemanticLogger::Loggable + + FULL_NAME_ATTRIBUTES = %i[full_name gem_full_name].freeze + + def collection + Version.includes(:rubygem).where(gem_platform: nil) + end + + def process(version) + platform = Gem::Platform.new(version.platform) + version.update!(gem_platform: platform.to_s) + rescue ActiveRecord::RecordInvalid => e + if e.record.errors.errors.all? { |error| FULL_NAME_ATTRIBUTES.include?(error.attribute) && error.type == :taken } + version.save!(validate: false) + logger.warn "Version #{version.full_name} failed validation setting gem_platform to #{platform.to_s.inspect} but was saved without validation", + error: e + else + logger.error "Version #{version.full_name} failed validation setting gem_platform to #{platform.to_s.inspect}", error: e + end + end +end diff --git a/app/tasks/maintenance/backfill_linkset_links_to_version_metadata_task.rb b/app/tasks/maintenance/backfill_linkset_links_to_version_metadata_task.rb new file mode 100644 index 00000000000..eddbb04aa3f --- /dev/null +++ b/app/tasks/maintenance/backfill_linkset_links_to_version_metadata_task.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class Maintenance::BackfillLinksetLinksToVersionMetadataTask < MaintenanceTasks::Task + def collection + Version.includes(:rubygem, rubygem: [:linkset]) + end + + def process(version) + return unless (linkset = version.rubygem.linkset) + + if version.metadata_uri_set? + # only the homepage does not respect #metadata_uri_set? + backfill_links(version, linkset, Links::LINKS.slice("home")) + else + backfill_links(version, linkset, Links::LINKS) + end + end + + private + + def backfill_links(version, linkset, links) + # would need a transaction since we're updating multiple attributes and + # metadata_uri_set? needs to be updated atomically to keep the backfill idempotent, + # but there is only a single update being issued here + + changes = false + links.each do |short, long| + next if version.metadata[long].present? + + next unless (value = linkset[short.to_sym]) + + version.metadata[long] = value + changes = true + end + version.save! if changes + end +end diff --git a/app/tasks/maintenance/backfill_spec_sha256_task.rb b/app/tasks/maintenance/backfill_spec_sha256_task.rb new file mode 100644 index 00000000000..a41b79a5ac3 --- /dev/null +++ b/app/tasks/maintenance/backfill_spec_sha256_task.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class Maintenance::BackfillSpecSha256Task < MaintenanceTasks::Task + include SemanticLogger::Loggable + + def collection + Version.indexed.where(spec_sha256: nil) + end + + def process(version) + logger.tagged(version_id: version.id, name: version.rubygem.name, number: version.number, platform: version.platform) do + logger.info "Updating spec_sha256 for #{version.full_name}" + + spec_path = "quick/Marshal.4.8/#{version.full_name}.gemspec.rz" + spec_contents = RubygemFs.instance.get(spec_path) + + if spec_contents.nil? + logger.error "Could not find #{spec_path}" + return + end + + spec_sha256 = Digest::SHA2.base64digest(spec_contents) + + logger.info "Updating spec_sha256 for #{version.full_name} to #{spec_sha256}" + + version.transaction do + if version.reload.spec_sha256.present? + if spec_sha256 != version.spec_sha256 + raise "Version #{version.full_name} has incorrect spec_sha256 (expected #{version.spec_sha256}, got #{spec_sha256})" + end + else + version.update!(spec_sha256: spec_sha256) + end + end + end + end +end diff --git a/app/tasks/maintenance/upload_info_files_to_s3_task.rb b/app/tasks/maintenance/upload_info_files_to_s3_task.rb new file mode 100644 index 00000000000..8810166ecc3 --- /dev/null +++ b/app/tasks/maintenance/upload_info_files_to_s3_task.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Maintenance::UploadInfoFilesToS3Task < MaintenanceTasks::Task + def collection + Rubygem.with_versions + end + + def process(rubygem) + UploadInfoFileJob.perform_later(rubygem_name: rubygem.name) + end +end diff --git a/app/tasks/maintenance/user_totp_seed_empty_to_nil_task.rb b/app/tasks/maintenance/user_totp_seed_empty_to_nil_task.rb new file mode 100644 index 00000000000..71830b6af85 --- /dev/null +++ b/app/tasks/maintenance/user_totp_seed_empty_to_nil_task.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class Maintenance::UserTotpSeedEmptyToNilTask < MaintenanceTasks::Task + def collection + User.where(totp_seed: "") + end + + def process(element) + element.transaction do + element.update!(totp_seed: nil) if element.totp_seed == "" + end + end + + delegate :count, to: :collection +end diff --git a/app/tasks/maintenance/verify_gem_contents_in_fs_task.rb b/app/tasks/maintenance/verify_gem_contents_in_fs_task.rb new file mode 100644 index 00000000000..1f5b9b7bf2d --- /dev/null +++ b/app/tasks/maintenance/verify_gem_contents_in_fs_task.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class Maintenance::VerifyGemContentsInFsTask < MaintenanceTasks::Task + include SemanticLogger::Loggable + + attribute :gem_name_pattern, :string + attribute :version_pattern, :string + attribute :platform_pattern, :string + attribute :full_name_pattern, :string + + attribute :only_indexed, :boolean, default: true + + validate :patterns_are_valid + + def collection + collection = Version.all + collection = collection.indexed if only_indexed + + collection = collection.joins(:rubygem).where("rubygems.name ~ ?", gem_name_pattern) if gem_name_pattern.present? + collection = matches_regexp(collection, :version, version_pattern) if version_pattern.present? + collection = matches_regexp(collection, :platform, platform_pattern) if platform_pattern.present? + collection = matches_regexp(collection, :full_name, full_name_pattern) if full_name_pattern.present? + + collection + end + + def process(version) + logger.tagged(version_id: version.id, name: version.rubygem.name, number: version.number, platform: version.platform) do + validate_checksum(version, "gem", "gems/#{version.gem_file_name}", version.sha256) + validate_checksum(version, "spec", "quick/Marshal.4.8/#{version.full_name}.gemspec.rz", version.spec_sha256) + end + end + + private + + def patterns_are_valid + %i[gem_name_pattern version_pattern platform_pattern full_name_pattern].each do |pattern| + next if send(pattern).blank? + begin + Regexp.new(send(pattern)) + rescue RegexpError + errors.add(pattern, "is not a valid regular expression") + end + end + end + + def matches_regexp(collection, field, regexp) + collection.where(collection.arel_table[field].matches_regexp(regexp)) + end + + def validate_checksum(version, name, path, expected_checksum) + logger.warn "Version #{version.fullname} has no #{name} checksum" if expected_checksum.blank? + contents = RubygemFs.instance.get(path) + logger.warn "Version #{version.full_name} is missing #{name} contents (#{path})" if contents.blank? + + return unless contents.present? && expected_checksum.present? + sha256 = Digest::SHA256.base64digest(contents) + logger.error "#{path} has incorrect checksum (expected #{expected_checksum}, got #{sha256})" if sha256 != expected_checksum + end +end diff --git a/app/views/adoptions/index.html.erb b/app/views/adoptions/index.html.erb new file mode 100644 index 00000000000..7d6ae93012f --- /dev/null +++ b/app/views/adoptions/index.html.erb @@ -0,0 +1,40 @@ +<% @title_for_header_only = t('.title') %> + +<% content_for :title do %> +

    + <%= t('.title') %> + <% if policy(@rubygem).manage_adoption? %> + <%= t(".subtitle_owner_html", gem: @rubygem.name) %> + <% else %> + <%= t(".subtitle_user_html", gem: @rubygem.name) %> + <% end %> +

    +<% end %> + +
    +

    <%= t(".ownership_calls") %> + [?] +

    +
    + +<% if @ownership_call %> +
    + <%= sanitize_note(@ownership_call.note) %> +

    + <%= t("ownership_calls.created_by") %>: + <%= link_to @ownership_call.user_display_handle, profile_path(@ownership_call.user), class: "t-text t-link" %> +

    + <% if policy(@ownership_call).close? %> + <%= button_to t("ownership_calls.close"), close_rubygem_ownership_calls_path(@ownership_call.rubygem.slug), method: :patch, class: "form__submit form__submit--medium" %> + <% end %> +
    + +<% elsif policy(@rubygem).manage_adoption? %> + <%= render partial: "ownership_calls/form", locals: { gem: @rubygem.name } %> +<% else %> +
    +

    <%= t(".no_ownership_calls", gem: @rubygem.name) %>

    +
    +<% end %> + +<%= render "ownership_requests/list" %> diff --git a/app/views/api_keys/_form.html.erb b/app/views/api_keys/_form.html.erb new file mode 100644 index 00000000000..245ec1d52ec --- /dev/null +++ b/app/views/api_keys/_form.html.erb @@ -0,0 +1,46 @@ +<%= form_with model: [:profile, @api_key], data: { controller: "exclusive-checkbox gem-scope" } do |f| %> + <%= label_tag :name, t("api_keys.index.name"), class: "form__label" %> + <%= f.text_field :name, class: "form__input", autocomplete: :off, disabled: f.object.persisted? %> + +
    + <%= label_tag :scope, t(".exclusive_scopes"), class: "form__label" %> + <% ApiKey::EXCLUSIVE_SCOPES.each do |api_scope| %> +
    + <%= api_key_checkbox(f, api_scope) %> + <%= label_tag api_scope, t("api_keys.index.#{api_scope}"), class: "form__checkbox__label" %> +
    + <% end %> +
    +
    + <%= label_tag :scope, t("api_keys.index.scopes"), class: "form__label" %> + <% (ApiKey::API_SCOPES - ApiKey::EXCLUSIVE_SCOPES).each do |api_scope| %> +
    + <%= api_key_checkbox(f, api_scope) %> + <%= label_tag api_scope, t("api_keys.index.#{api_scope}"), class: "form__checkbox__label" %> +
    + <% end %> +
    + +
    + <%= label_tag :rubygem_id, t(".rubygem_scope"), class: "form__label" %> +

    <%= t(".rubygem_scope_info") %>

    + <%= f.collection_select :rubygem_id, current_user.rubygems.by_name, :id, :name, { include_blank: t("api_keys.all_gems") }, selected: :rubygem_id, class: "form__input form__select", data: { gem_scope_target: "selector" } %> +
    + +
    + <%= label_tag :expires_at, t(".expiration"), class: "form__label" %> + <%= f.datetime_field :expires_at, min: 5.minutes.from_now, include_seconds: false, class: "form__input", disabled: f.object.persisted? %> +
    + + <% unless current_user.mfa_disabled? || current_user.mfa_ui_and_api? %> +
    + <%= label_tag :mfa, t(".multifactor_auth"), class: "form__label" %> +
    + <%= f.check_box :mfa, { class: "form__checkbox__input", id: :mfa, checked: true } , "true", "false" %> + <%= label_tag :mfa, t(".enable_mfa"), class: "form__checkbox__label" %> +
    +
    + <% end %> + + <%= f.submit class: "form__submit" %> +<% end %> diff --git a/app/views/api_keys/edit.html.erb b/app/views/api_keys/edit.html.erb index 58ba04e6465..8035910c6e2 100644 --- a/app/views/api_keys/edit.html.erb +++ b/app/views/api_keys/edit.html.erb @@ -1,18 +1,5 @@ <% @title = t(".edit_api_key") %>
    - <%= form_for @api_key, url: profile_api_key_path do |f| %> - <%= label_tag :name, t("api_keys.index.name"), class: "form__label" %> - <%= f.text_field :name, class: "form__input", autocomplete: :off %> - - <%= label_tag :scope, t("api_keys.index.scope"), class: "form__label" %> - <% ApiKey::API_SCOPES.each do |api_scope| %> -
    - <%= f.check_box "#{api_scope}", { class: "form__checkbox__input", id: "#{api_scope}" } , "true", "false" %> - <%= label_tag api_scope, t("api_keys.index.#{api_scope}"), class: "form__checkbox__label" %> -
    - <% end %> - - <%= f.submit "Update", class: "form__submit" %> - <% end %> + <%= render "form", api_key: @api_key %>
    diff --git a/app/views/api_keys/index.html.erb b/app/views/api_keys/index.html.erb index 017e766d0dc..c1816ee1a9c 100644 --- a/app/views/api_keys/index.html.erb +++ b/app/views/api_keys/index.html.erb @@ -9,6 +9,12 @@ <% end %> +
    +

    + <%= page_entries_info @api_keys, entry_name: 'API keys' %> +

    +
    +
    @@ -18,12 +24,23 @@ + + <% if current_user.mfa_enabled? %> + + <% end %> + <% for api_key in @api_keys do %> - + + + <% if current_user.mfa_enabled? %> + + <% end %> + <% end %> + @@ -70,6 +103,14 @@ + + + <% if current_user.mfa_enabled? %> + + <% end %>
    <%= t(".scopes") %> + <%= t(".gem") %> + <%= t(".age") %> + <%= t(".mfa") %> + <%= t(".last_access") %> + <%= t(".expiration") %> + @@ -31,24 +48,39 @@
    <%= api_key.name %> - <%= i18n_api_scopes api_key %> + <% api_key.scopes.each do |scope| %> +
      <%= t(".#{scope}") %>
    + <% end %> +
    + <%= gem_scope(api_key) %> <%= time_ago_in_words(api_key.created_at) %> + <%= image_tag("/images/check.svg") if api_key.mfa? %> + <%= api_key.last_accessed_at&.strftime("%Y-%m-%d %H:%M %Z") %> + <%= api_key.expires_at&.strftime("%Y-%m-%d %H:%M %Z") %> + - <%= button_to t("edit"), - edit_profile_api_key_path(id: api_key.id), - method: :get, - class: "form__submit form__submit--small" %> + <% unless api_key.soft_deleted? %> + <%= button_to t("edit"), + edit_profile_api_key_path(id: api_key.id), + method: :get, + class: "form__submit form__submit--small" %> + <% end %> <%= button_to t(".delete"), @@ -59,6 +91,7 @@
    + + + <%= button_to t(".reset"), reset_profile_api_keys_path, @@ -80,5 +121,10 @@
    + <%= paginate @api_keys %> +

    <%= button_to t(".new_key"), new_profile_api_key_path, method: "get", class: "form__submit" %>

    + <% if current_user.oidc_api_key_roles.any? %> +

    <%= link_to t("oidc.api_key_roles.index.api_key_roles"), profile_oidc_api_key_roles_path, class: "t-link t-underline" %> →

    + <% end %>
    diff --git a/app/views/api_keys/new.html.erb b/app/views/api_keys/new.html.erb index f61f3c23881..127d2057b6a 100644 --- a/app/views/api_keys/new.html.erb +++ b/app/views/api_keys/new.html.erb @@ -1,18 +1,5 @@ <% @title = t(".new_api_key") %>
    - <%= form_for @api_key, url: profile_api_keys_path do |f| %> - <%= label_tag :name, t("api_keys.index.name"), class: "form__label" %> - <%= f.text_field :name, class: "form__input", autocomplete: :off %> - - <%= label_tag :scope, t("api_keys.index.scope"), class: "form__label" %> - <% ApiKey::API_SCOPES.each do |api_scope| %> -
    - <%= f.check_box "#{api_scope}", { class: "form__checkbox__input", id: "#{api_scope}" } , "true", "false" %> - <%= label_tag api_scope, t("api_keys.index.#{api_scope}"), class: "form__checkbox__label" %> -
    - <% end %> - - <%= f.submit "Create", class: "form__submit" %> - <% end %> + <%= render "form", api_key: @api_key %>
    diff --git a/app/views/application_view.rb b/app/views/application_view.rb new file mode 100644 index 00000000000..42743b7431a --- /dev/null +++ b/app/views/application_view.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class ApplicationView < ApplicationComponent + # The ApplicationView is an abstract class for all your views. + + # By default, it inherits from `ApplicationComponent`, but you + # can change that to `Phlex::HTML` if you want to keep views and + # components independent. + + def title=(title) + @_view_context.instance_variable_set :@title, title + end + + def title_for_header_only=(title) + @_view_context.instance_variable_set :@title_for_header_only, title + end +end diff --git a/app/views/avo/cards/_dashboard_welcome_card.html.erb b/app/views/avo/cards/_dashboard_welcome_card.html.erb new file mode 100644 index 00000000000..380e5f215aa --- /dev/null +++ b/app/views/avo/cards/_dashboard_welcome_card.html.erb @@ -0,0 +1,7 @@ +
    +
    +

    + You are currently logged in as <%= link_to _current_user.login, avo.resources_admin_github_user_path(_current_user) %> +

    +
    +
    diff --git a/app/views/avo/login.html.erb b/app/views/avo/login.html.erb new file mode 100644 index 00000000000..c9cda781715 --- /dev/null +++ b/app/views/avo/login.html.erb @@ -0,0 +1,46 @@ + + + + + + + Log in | RubyGems.org + + +
    +
    + 422 error +
    +
    +

    To reach the admin panel, please log in via GitHub.

    + <%= form_tag ActionDispatch::Http::URL.path_for(path: '/oauth/github', params: { origin: request.fullpath }, data: { turbo: false }) do %> + + <% end %> + + <% if Gemcutter::ENABLE_DEVELOPMENT_ADMIN_LOG_IN %> +

    Since this is development mode, you can choose any admin from the list below to log in as that admin.

    + <% if Admin::GitHubUser.any? %> +
      + <% Admin::GitHubUser.find_each do |user| %> +
    • <%= link_to "login as #{user.login}", controller: :oauth, action: :development_log_in_as, admin_github_user_id: user.id %>
    • + <% end %> +
    + <% else %> +

    (To create an admin user for this list, run bin/rails db:seed.)

    + <% end %> + <% end%> + +

    + Back to RubyGems.org → +

    +
    +
    + + + +<% request.commit_csrf_token %> diff --git a/app/views/avo/partials/_head.html.erb b/app/views/avo/partials/_head.html.erb new file mode 100644 index 00000000000..ae35ace68a6 --- /dev/null +++ b/app/views/avo/partials/_head.html.erb @@ -0,0 +1 @@ +<%= javascript_importmap_tags "avo.custom" %> diff --git a/app/views/avo/partials/_pre_head.html.erb b/app/views/avo/partials/_pre_head.html.erb new file mode 100644 index 00000000000..e69de29bb2d diff --git a/app/views/components/application_component.rb b/app/views/components/application_component.rb new file mode 100644 index 00000000000..75773f70368 --- /dev/null +++ b/app/views/components/application_component.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class ApplicationComponent < Phlex::HTML + include Phlex::Rails::Helpers::Routes + + class TranslationHelper + include ActionView::Helpers::TranslationHelper + + def initialize(translation_path:) + @translation_path = translation_path + end + + private + + def scope_key_by_partial(key) + return key unless key&.start_with?(".") + + "#{@translation_path}#{key}" + end + end + + delegate :t, to: "self.class.translation_helper" + + def self.translation_helper + @translation_helper ||= TranslationHelper.new(translation_path:) + end + + def self.translation_path + @translation_path ||= name&.dup.tap do |n| + n.gsub!(/(::[^:]+)View/, '\1') + n.gsub!("::", ".") + n.gsub!(/([a-z])([A-Z])/, '\1_\2') + n.downcase! + end + end +end diff --git a/app/views/components/events/rubygem_event/owner/added_component.rb b/app/views/components/events/rubygem_event/owner/added_component.rb new file mode 100644 index 00000000000..3ea1d63e1bb --- /dev/null +++ b/app/views/components/events/rubygem_event/owner/added_component.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class Events::RubygemEvent::Owner::AddedComponent < Events::TableDetailsComponent + def view_template + plain t(".owner_added_owner_html", owner: link_to_user_from_gid(additional.owner_gid, additional.owner)) + return unless (authorizer = additional.authorizer.presence) + br + plain t(".owner_added_authorizer_html", authorizer: link_to_user_from_gid(additional.actor_gid, authorizer)) + end +end diff --git a/app/views/components/events/rubygem_event/owner/confirmed_component.rb b/app/views/components/events/rubygem_event/owner/confirmed_component.rb new file mode 100644 index 00000000000..47340537518 --- /dev/null +++ b/app/views/components/events/rubygem_event/owner/confirmed_component.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Events::RubygemEvent::Owner::ConfirmedComponent < Events::TableDetailsComponent + def view_template + div { t(".owner_added_owner_html", owner: link_to_user_from_gid(additional["owner_gid"], additional["owner"])) } + return unless (authorizer = additional["authorizer"].presence) + div { t(".owner_added_authorizer_html", authorizer:) } + end +end diff --git a/app/views/components/events/rubygem_event/owner/removed_component.rb b/app/views/components/events/rubygem_event/owner/removed_component.rb new file mode 100644 index 00000000000..64cedde811f --- /dev/null +++ b/app/views/components/events/rubygem_event/owner/removed_component.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Events::RubygemEvent::Owner::RemovedComponent < Events::TableDetailsComponent + def view_template + div { t(".owner_removed_owner_html", owner: link_to_user_from_gid(additional["owner_gid"], additional["owner"])) } + return unless (remover = additional["removed_by"].presence) + div { t(".owner_removed_by_html", remover: link_to_user_from_gid(additional["actor_gid"], remover)) } + end +end diff --git a/app/views/components/events/rubygem_event/version/pushed_component.rb b/app/views/components/events/rubygem_event/version/pushed_component.rb new file mode 100644 index 00000000000..00901a97f42 --- /dev/null +++ b/app/views/components/events/rubygem_event/version/pushed_component.rb @@ -0,0 +1,18 @@ +class Events::RubygemEvent::Version::PushedComponent < Events::TableDetailsComponent + delegate :rubygem, to: :event + + def view_template + div do + t(".version_html", version: + link_to_version_from_gid(additional.version_gid, additional.number, additional.platform)) + end + if additional.sha256.present? + sha256 = capture { code(class: "tw-break-all") { additional.sha256 } } + div { t(".version_pushed_sha256_html", sha256:) } + end + return if additional.pushed_by.blank? + div do + t(".version_pushed_by_html", pusher: link_to_user_from_gid(additional.actor_gid, additional.pushed_by)) + end + end +end diff --git a/app/views/components/events/rubygem_event/version/unyanked_component.rb b/app/views/components/events/rubygem_event/version/unyanked_component.rb new file mode 100644 index 00000000000..7ec7838b949 --- /dev/null +++ b/app/views/components/events/rubygem_event/version/unyanked_component.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Events::RubygemEvent::Version::UnyankedComponent < Events::TableDetailsComponent + def view_template + plain t(".version_html", version: link_to_version_from_gid(additional.version_gid, additional.number, additional.platform)) + end +end diff --git a/app/views/components/events/rubygem_event/version/yank_forbidden_component.rb b/app/views/components/events/rubygem_event/version/yank_forbidden_component.rb new file mode 100644 index 00000000000..41eff628651 --- /dev/null +++ b/app/views/components/events/rubygem_event/version/yank_forbidden_component.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class Events::RubygemEvent::Version::YankForbiddenComponent < Events::TableDetailsComponent + def view_template + div do + t(".version_html", version: + link_to_version_from_gid(additional.version_gid, additional.number, additional.platform)) + end + return if additional.yanked_by.blank? + div do + t(".version_yanked_by_html", pusher: link_to_user_from_gid(additional.actor_gid, additional.yanked_by)) + end + end +end diff --git a/app/views/components/events/rubygem_event/version/yanked_component.rb b/app/views/components/events/rubygem_event/version/yanked_component.rb new file mode 100644 index 00000000000..8e6d0984e3e --- /dev/null +++ b/app/views/components/events/rubygem_event/version/yanked_component.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class Events::RubygemEvent::Version::YankedComponent < Events::TableDetailsComponent + def view_template + div do + t(".version_html", version: + link_to_version_from_gid(additional.version_gid, additional.number, additional.platform)) + end + return if additional.yanked_by.blank? + div do + t(".version_yanked_by_html", pusher: link_to_user_from_gid(additional.actor_gid, additional.yanked_by)) + end + end +end diff --git a/app/views/components/events/table_component.rb b/app/views/components/events/table_component.rb new file mode 100644 index 00000000000..e6955d33857 --- /dev/null +++ b/app/views/components/events/table_component.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +class Events::TableComponent < ApplicationComponent + include Phlex::DeferredRender + include Phlex::Rails::Helpers::DistanceOfTimeInWordsToNow + include Phlex::Rails::Helpers::TimeTag + include Phlex::Rails::Helpers::DOMClass + + extend Phlex::Rails::HelperMacros + + register_value_helper :current_user + register_value_helper :page_entries_info + register_value_helper :paginate + + extend Dry::Initializer + + option :security_events + + def view_template + header(class: "gems__header push--s") do + p(class: "gems__meter l-mb-0") { plain page_entries_info(security_events) } + end + + if security_events.any? + table(class: "owners__table") do + thead do + tr(class: "owners__row owners__header") do + th(class: "owners__cell") { t(".event") } + th(class: "owners__cell") { t(".time") } + th(class: "owners__cell") { t(".additional_info") } + end + end + + tbody(class: "t-body") do + security_events.each do |token| + row(token) + end + end + end + end + + plain paginate(security_events) + end + + private + + def tag_header(tag) + key = Events::Tag.translation_key(tag) + h4 { t(key, default: tag.to_s) } + end + + def row(event) + tr(class: "owners__row #{dom_class(event)}__#{event.tag}") do + td(class: "owners__cell !tw-text-start") do + event_details(event) + end + td(class: "owners__cell") do + time_tag(event.created_at, distance_of_time_in_words_to_now(event.created_at)) + plain " ago" + end + td(class: "owners__cell !tw-text-start") do + additional_info(event) + end + end + end + + def event_details(event) + tag_header event.tag + component_name = "#{event.class.name}::#{Events::Tag.const_name(event.tag)}Component" + component = component_name.safe_constantize + return if component.blank? + render component.new(event:) + end + + def additional_info(event) + return unless event.tags.key?(event.tag) + return if event.geoip_info.nil? && (event.additional.blank? && event.additional.user_agent_info.nil?) + + p(class: "!tw-mb-0") do + break div { t(".redacted") } unless show_additional_info?(event) + + if event.geoip_info.present? + div do + plain event.geoip_info.to_s + end + end + + div do + ua = event.additional.user_agent_info + plain ua&.to_s || t(".no_user_agent_info") + end + end + end + + def show_additional_info?(event) + return true if event.has_attribute?(:user_id) && event.user == current_user + return true if event.additional.has_attribute?("actor_gid") && event.additional.actor_gid == current_user&.to_gid + + false + end +end diff --git a/app/views/components/events/table_details_component.rb b/app/views/components/events/table_details_component.rb new file mode 100644 index 00000000000..c97cf39ae79 --- /dev/null +++ b/app/views/components/events/table_details_component.rb @@ -0,0 +1,42 @@ +class Events::TableDetailsComponent < ApplicationComponent + extend Dry::Initializer + + option :event + delegate :additional, :rubygem, to: :event + + def view_template + raise NotImplementedError + end + + def self.translation_path + super.gsub(/\.[^.]+\z/, "") + end + + private + + def link_to_user_from_gid(gid, text) + user = load_gid(gid, only: User) + + if user + helpers.link_to text, profile_path(user.display_id), alt: user.display_handle, title: user.display_handle + else + text + end + end + + def link_to_version_from_gid(gid, number, platform) + version = load_gid(gid, only: Version) + + if version + helpers.link_to version.to_title, rubygem_version_path(version.rubygem.slug, version.slug) + else + "#{rubygem.name} (#{number}#{platform.blank? || platform == 'ruby' ? '' : "-#{platform}"})" + end + end + + def load_gid(gid, only: []) + gid&.find(only:) + rescue ActiveRecord::RecordNotFound + nil + end +end diff --git a/app/views/components/events/user_event/api_key/created_component.rb b/app/views/components/events/user_event/api_key/created_component.rb new file mode 100644 index 00000000000..c922d99422d --- /dev/null +++ b/app/views/components/events/user_event/api_key/created_component.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class Events::UserEvent::ApiKey::CreatedComponent < Events::TableDetailsComponent + def view_template + div { t(".api_key_name", name: additional.name) } + div { t(".api_key_scopes", scopes: additional.scopes&.to_sentence) } + if additional.gem.present? + div do + t(".api_key_gem_html", gem: helpers.link_to(additional.gem, rubygem_path(additional.gem))) + end + end + div { t(".api_key_mfa", mfa: additional.mfa ? t(".required") : t(".not_required")) } if additional.has_attribute?(:mfa) + end +end diff --git a/app/views/components/events/user_event/api_key/deleted_component.rb b/app/views/components/events/user_event/api_key/deleted_component.rb new file mode 100644 index 00000000000..ebcf279aa78 --- /dev/null +++ b/app/views/components/events/user_event/api_key/deleted_component.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Events::UserEvent::ApiKey::DeletedComponent < Events::TableDetailsComponent + def view_template + plain t(".api_key_name", name: event.additional.name) + end +end diff --git a/app/views/components/events/user_event/email/added_component.rb b/app/views/components/events/user_event/email/added_component.rb new file mode 100644 index 00000000000..e805e828460 --- /dev/null +++ b/app/views/components/events/user_event/email/added_component.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Events::UserEvent::Email::AddedComponent < Events::TableDetailsComponent + def view_template + code { plain additional.email } + end +end diff --git a/app/views/components/events/user_event/email/sent_component.rb b/app/views/components/events/user_event/email/sent_component.rb new file mode 100644 index 00000000000..3225f2e9d5e --- /dev/null +++ b/app/views/components/events/user_event/email/sent_component.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Events::UserEvent::Email::SentComponent < Events::TableDetailsComponent + def view_template + plain t(".email_sent_subject", subject: additional.subject) + br + plain t(".email_sent_from", from: additional.from) + br + plain t(".email_sent_to", to: additional.to) + end +end diff --git a/app/views/components/events/user_event/email/verified_component.rb b/app/views/components/events/user_event/email/verified_component.rb new file mode 100644 index 00000000000..133eee7f02a --- /dev/null +++ b/app/views/components/events/user_event/email/verified_component.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Events::UserEvent::Email::VerifiedComponent < Events::TableDetailsComponent + def view_template + email = additional.email + return if email.blank? + code { plain email } + end +end diff --git a/app/views/components/events/user_event/login/success_component.rb b/app/views/components/events/user_event/login/success_component.rb new file mode 100644 index 00000000000..5406df33b82 --- /dev/null +++ b/app/views/components/events/user_event/login/success_component.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Events::UserEvent::Login::SuccessComponent < Events::TableDetailsComponent + def view_template + if additional.authentication_method == "webauthn" + plain t(".webauthn_login", device: additional.two_factor_label) + elsif additional.two_factor_method.blank? + plain t(".mfa_method", method: t(".none")) + elsif additional.two_factor_method == "webauthn" + plain t(".mfa_method", method: "WebAuthn") + br + plain t(".mfa_device", device: additional.two_factor_label) + else + plain t(".mfa_method", method: additional.two_factor_method) + end + end +end diff --git a/app/views/components/events/user_event/user/created_component.rb b/app/views/components/events/user_event/user/created_component.rb new file mode 100644 index 00000000000..3d2cca3d8c3 --- /dev/null +++ b/app/views/components/events/user_event/user/created_component.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Events::UserEvent::User::CreatedComponent < Events::TableDetailsComponent + def view_template + plain t(".email", email: additional.email) + end +end diff --git a/app/views/components/oidc/api_key_role/table_component.rb b/app/views/components/oidc/api_key_role/table_component.rb new file mode 100644 index 00000000000..dce49298fb9 --- /dev/null +++ b/app/views/components/oidc/api_key_role/table_component.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class OIDC::ApiKeyRole::TableComponent < ApplicationComponent + include Phlex::Rails::Helpers::LinkTo + extend Dry::Initializer + + option :api_key_roles + + def view_template + table(class: "t-body") do + thead do + tr(class: "owners__row owners__header") do + header { OIDC::ApiKeyRole.human_attribute_name(:name) } + header { OIDC::ApiKeyRole.human_attribute_name(:token) } + header { OIDC::ApiKeyRole.human_attribute_name(:issuer) } + end + end + + tbody(class: "t-body") do + api_key_roles.each do |api_key_role| + tr(class: "owners__row") do + cell(title: "Name") { link_to api_key_role.name, profile_oidc_api_key_role_path(api_key_role.token) } + cell(title: "Role Token") { code { api_key_role.token } } + cell(title: "Provider") { link_to api_key_role.provider.issuer, api_key_role.provider.issuer } + end + end + end + end + end + + private + + def header(&) + th(class: "owners_cell", &) + end + + def cell(title:, &) + td(class: "owners__cell", data: { title: }, &) + end +end diff --git a/app/views/components/oidc/id_token/key_value_pairs_component.rb b/app/views/components/oidc/id_token/key_value_pairs_component.rb new file mode 100644 index 00000000000..ed71fe2d19f --- /dev/null +++ b/app/views/components/oidc/id_token/key_value_pairs_component.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class OIDC::IdToken::KeyValuePairsComponent < ApplicationComponent + extend Dry::Initializer + option :pairs + + def view_template + dl(class: "t-body provider_attributes full-width overflow-wrap") do + pairs.each do |key, val| + dt(class: "adoption__heading text-right") { code { key } } + dd { code { val } } + end + end + end +end diff --git a/app/views/components/oidc/id_token/table_component.rb b/app/views/components/oidc/id_token/table_component.rb new file mode 100644 index 00000000000..da8b45b90a0 --- /dev/null +++ b/app/views/components/oidc/id_token/table_component.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class OIDC::IdToken::TableComponent < ApplicationComponent + extend Dry::Initializer + option :id_tokens + + include Phlex::Rails::Helpers::TimeTag + include Phlex::Rails::Helpers::LinkToUnlessCurrent + + def view_template + table(class: "owners__table") do + thead do + tr(class: "owners__row owners__header") do + th(class: "owners__cell") { OIDC::IdToken.human_attribute_name(:created_at) } + th(class: "owners__cell") { OIDC::IdToken.human_attribute_name(:expires_at) } + th(class: "owners__cell") { OIDC::IdToken.human_attribute_name(:api_key_role) } + th(class: "owners__cell") { OIDC::IdToken.human_attribute_name(:jti) } + end + end + + tbody(class: "t-body") do + id_tokens.each do |token| + row(token) + end + end + end + end + + private + + def row(token) + tr(**classes("owners__row", -> { token.api_key.expired? } => "owners__row__invalid")) do + td(class: "owners__cell") { time_tag token.created_at } + td(class: "owners__cell") { time_tag token.api_key.expires_at } + td(class: "owners__cell") { link_to_unless_current token.api_key_role.name, profile_oidc_api_key_role_path(token.api_key_role.token) } + td(class: "owners__cell") { link_to_unless_current token.jti, profile_oidc_id_token_path(token), class: "recovery-code-list__item" } + end + end +end diff --git a/app/views/components/oidc/trusted_publisher/github_action/form_component.rb b/app/views/components/oidc/trusted_publisher/github_action/form_component.rb new file mode 100644 index 00000000000..677f57c0a45 --- /dev/null +++ b/app/views/components/oidc/trusted_publisher/github_action/form_component.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class OIDC::TrustedPublisher::GitHubAction::FormComponent < ApplicationComponent + extend Dry::Initializer + + option :github_action_form + + def view_template + github_action_form.fields_for :trusted_publisher do |trusted_publisher_form| + field trusted_publisher_form, :text_field, :repository_owner, autocomplete: :off + field trusted_publisher_form, :text_field, :repository_name, autocomplete: :off + field trusted_publisher_form, :text_field, :workflow_filename, autocomplete: :off + field trusted_publisher_form, :text_field, :environment, autocomplete: :off, optional: true + end + end + + private + + def field(form, type, name, optional: false, **) + form.label name, class: "form__label" do + plain form.object.class.human_attribute_name(name) + span(class: "t-text--s") { " (#{t('form.optional')})" } if optional + end + form.send(type, name, class: helpers.class_names("form__input", "tw-border tw-border-red-500" => form.object.errors.include?(name)), **) + p(class: "form__field__instructions") { t("oidc.trusted_publisher.github_actions.#{name}_help_html") } + end +end diff --git a/app/views/components/oidc/trusted_publisher/github_action/table_component.rb b/app/views/components/oidc/trusted_publisher/github_action/table_component.rb new file mode 100644 index 00000000000..9174f2d1602 --- /dev/null +++ b/app/views/components/oidc/trusted_publisher/github_action/table_component.rb @@ -0,0 +1,20 @@ +class OIDC::TrustedPublisher::GitHubAction::TableComponent < ApplicationComponent + extend Dry::Initializer + + option :github_action + + def view_template + dl(class: "tw-flex tw-flex-col sm:tw-grid sm:tw-grid-cols-2 tw-items-baseline tw-gap-4 full-width overflow-wrap") do + dt(class: "adoption__heading ") { "GitHub Repository" } + dd { code { github_action.repository } } + + dt(class: "adoption__heading ") { "Workflow Filename" } + dd { code { github_action.workflow_filename } } + + if github_action.environment? + dt(class: "adoption__heading") { "Environment" } + dd { code { github_action.environment } } + end + end + end +end diff --git a/app/views/dashboards/show.html.erb b/app/views/dashboards/show.html.erb index 13ffa727528..49b2b76c30a 100644 --- a/app/views/dashboards/show.html.erb +++ b/app/views/dashboards/show.html.erb @@ -18,10 +18,10 @@
      <% @latest_updates.each do |version| %>
    1. - +
      <%= version.to_title %> - <%= t 'time_ago', :duration => time_ago_in_words(version.created_at) %> + <%= t 'time_ago', :duration => time_ago_in_words(version.authored_at) %>

      <%= short_info(version) %>

      @@ -46,7 +46,7 @@
        <% @my_gems.each do |rubygem| %>
      • - <%= link_to rubygem.name, rubygem, :title => short_info(rubygem.versions.most_recent), :class => 't-link' %> + <%= link_to rubygem.name, rubygem_path(rubygem.slug), :title => short_info(rubygem.most_recent_version), :class => 't-link' %>
      • <% end %>
      @@ -57,7 +57,7 @@
        <% current_user.subscribed_gems.each do |gem| %>
      • - <%= link_to gem, gem, :title => short_info(gem.versions.most_recent), :class => 't-link' %> + <%= link_to gem, rubygem_path(gem.slug), :title => short_info(gem.most_recent_version), :class => 't-link' %>
      • <% end %>
      diff --git a/app/views/dependencies/_dependencies.html.erb b/app/views/dependencies/_dependencies.html.erb index f5568e662af..c7a311d6f61 100644 --- a/app/views/dependencies/_dependencies.html.erb +++ b/app/views/dependencies/_dependencies.html.erb @@ -5,12 +5,12 @@
      • - + name, "data-version" => version) %>> - + <%= link_to "/gems/#{name}/versions/#{version}", target: :_blank do %> <%= name %> <%= version %> <%= req %> - + <% end %>
        diff --git a/app/views/dependencies/show.html.erb b/app/views/dependencies/show.html.erb index 58566c30060..2fa5157fd1f 100644 --- a/app/views/dependencies/show.html.erb +++ b/app/views/dependencies/show.html.erb @@ -2,6 +2,10 @@ <% @subtitle = @latest_version.try(:slug) %> <% if @dependencies.present? && @latest_version.indexed %> +
        +

        <%= t ".click_to_expand" %>

        +
        +
        <%= render partial: "dependencies/dependencies", locals: { scope: "runtime", dependencies: @dependencies, gem_name: @latest_version.rubygem.name } %> <%= render partial: "dependencies/dependencies", locals: { scope: "development", dependencies: @dependencies, gem_name: @latest_version.rubygem.name } %> diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb index c81f25bba8b..e7b01649894 100644 --- a/app/views/home/index.html.erb +++ b/app/views/home/index.html.erb @@ -2,18 +2,7 @@

        <%= t '.find_blurb' %>

        -
        - <%= form_tag search_path, :method => :get do %> - <%= search_field_tag :query, params[:query], :placeholder => t('layouts.application.header.search_gem_html'), autofocus: current_page?(root_url), :id => 'home_query', :class => "home__search" %> - <%= label_tag :home_query do %> - <%= t('layouts.application.header.search_gem_html') %> -
        - <%= link_to t("advanced_search"), advanced_search_path, class: "home__advanced__search t-link--has-arrow"%> -
        - <% end %> - <%= submit_tag '⌕', :name => nil, :class => "home__search__icon" %> - <% end %> -
        +<%= render "layouts/search" %>
        <% if @downloads_count %>

        diff --git a/app/views/layouts/_search.html.erb b/app/views/layouts/_search.html.erb new file mode 100644 index 00000000000..c711f56a75f --- /dev/null +++ b/app/views/layouts/_search.html.erb @@ -0,0 +1,24 @@ +<% home = current_page?(root_path) || current_page?(advanced_search_path) %> +
        " role="search"> + <%= form_tag search_path, method: :get, data: { controller: "autocomplete", autocomplete_selected_class: "selected", } do %> + <%= rubygem_search_field(home: home) %> + +
          + + + + <%= label_tag :query, id: "querylabel" do %> + <%= t('layouts.application.header.search_gem_html') %> + <% end %> + + <%= submit_tag '⌕', id: "search_submit", name: nil, class: home ? "home__search__icon" : "header__search__icon", aria: { labelledby: "querylabel" } %> + + <% if home %> +
          + <%= link_to t("advanced_search"), advanced_search_path, class: "home__advanced__search t-link--has-arrow"%> +
          + <% end %> + <% end %> +
          diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 198a8536a63..59fd44a0de8 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,5 +1,5 @@ - + <%= page_title %> @@ -20,33 +20,34 @@ <%= render "layouts/feeds" %> <%= csrf_meta_tag %> <%= yield :head %> + <%= javascript_importmap_tags %> - -
          + + + <% if content_for?(:banner) %> + + <% end %> +
          - <%= link_to(root_path, title: 'RubyGems', class: 'header__logo-wrap') do %> + <%= link_to(root_path, title: 'RubyGems', class: 'header__logo-wrap', data: { nav_target: "logo" }) do %> RubyGems <% end %> - + Navigation menu

          <% end %> @@ -124,7 +121,7 @@ <% end %> -
          + - - <%= javascript_include_tag "application" %> <%= yield :javascript %> diff --git a/app/views/layouts/component_preview.html.erb b/app/views/layouts/component_preview.html.erb new file mode 100644 index 00000000000..7180f560b8a --- /dev/null +++ b/app/views/layouts/component_preview.html.erb @@ -0,0 +1,33 @@ + +"> + + Component Preview + + + <%= stylesheet_link_tag("application") %> + <%= stylesheet_link_tag("tailwind", "data-turbo-track": "reload") %> + + + <%= yield :head %> + + +
          " class="tw-mx-auto"> +
          + <% if @title %> +

          + <%= content_for_title @title, @title_url %> + <% if @subtitle %> + <%= @subtitle %> + <% end %> +

          + <% end %> + <%= yield :title %> +
          + <%= yield %> +
          +
          +
          + <%= javascript_include_tag "application" %> + <%= yield :javascript %> + + diff --git a/app/views/layouts/license.txt b/app/views/layouts/license.txt new file mode 100644 index 00000000000..e272ae5aa0c --- /dev/null +++ b/app/views/layouts/license.txt @@ -0,0 +1,210 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2013 Techdrop Labs Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +MODIFICATIONS: +- changed color pallet to match rubygems.org +- removed not so useful header and footer +- updated links and text in context of email confirmation and rubygems.org website +- move body section to seperate file +- add rubygems.org icon to header +GITHUB REPO: https://github.com/sendwithus/templates (template: goldstar/progress.html) +SCOPE: all HTML mailer templates diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb index 187922be96a..9ed5f8c0d5e 100644 --- a/app/views/layouts/mailer.html.erb +++ b/app/views/layouts/mailer.html.erb @@ -3,210 +3,6 @@ Version 2.0, January 2004 http://www.apache.org/licenses/ - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright 2013 Techdrop Labs Inc. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - - MODIFICATIONS: - - changed color pallet to match rubygems.org - - removed not so useful header and footer - - updated links and text in context of email confirmation and rubygems.org website - - move body section to seperate file - - add rubygems.org icon to header GITHUB REPO: https://github.com/sendwithus/templates (template: goldstar/progress.html) --> @@ -311,13 +107,13 @@
           
          @@ -402,10 +198,10 @@ - - - - + + + +
          " border="0" width="34" height="34" alt="" />" border="0" width="28" height="28" alt="" />" border="0" width="28" height="28" alt="" />
          " border="0" width="28" height="28" alt="" />" border="0" width="28" height="28" alt="" />" border="0" width="28" height="28" alt="" />
          diff --git a/app/views/mailer/_compromised_instructions.html.erb b/app/views/mailer/_compromised_instructions.html.erb index ba3958f94a7..1a5cf9d7fb0 100644 --- a/app/views/mailer/_compromised_instructions.html.erb +++ b/app/views/mailer/_compromised_instructions.html.erb @@ -3,7 +3,7 @@
          • <%= link_to("Change your password", new_password_url) %>
          • <%= link_to("Reset your API key", profile_api_keys_url) %>
          • -
          • <%= link_to("Enable multifactor authentication", edit_settings_url) %>
          • +
          • <%= link_to("Enable multi-factor authentication", edit_settings_url) %>
        • <%= yield %> diff --git a/app/views/mailer/_compromised_instructions.text.erb b/app/views/mailer/_compromised_instructions.text.erb new file mode 100644 index 00000000000..8fc5571841c --- /dev/null +++ b/app/views/mailer/_compromised_instructions.text.erb @@ -0,0 +1,6 @@ +1. If you suspect your account was compromised: + - Change your password (<%= new_password_url %>) + - Reset your API key (<%= profile_api_keys_url %>) + - Enable multi-factor authentication (<%= edit_settings_url %>) + <%= yield %> +2. Report this incident to RubyGems.org by emailing us at support@rubygems.org. diff --git a/app/views/mailer/admin_manual.html.erb b/app/views/mailer/admin_manual.html.erb new file mode 100644 index 00000000000..e53371b916d --- /dev/null +++ b/app/views/mailer/admin_manual.html.erb @@ -0,0 +1,20 @@ +<% @title = t(".title") %> + + + + + + + + +
          +
           
          + +
          + <%= simple_format @body %>
          +
          + +
           
          + +
          + diff --git a/app/views/mailer/admin_manual.text.erb b/app/views/mailer/admin_manual.text.erb new file mode 100644 index 00000000000..9e66ef58e6d --- /dev/null +++ b/app/views/mailer/admin_manual.text.erb @@ -0,0 +1,3 @@ +Hi <%= @user.handle %> + +<%= strip_tags @body %> diff --git a/app/views/mailer/api_key_created.html.erb b/app/views/mailer/api_key_created.html.erb index d4107c7117c..26a8acde96b 100644 --- a/app/views/mailer/api_key_created.html.erb +++ b/app/views/mailer/api_key_created.html.erb @@ -10,14 +10,23 @@

          - A new API key was created for your account on rubygems.org. + A new API key was created for your account on <%= Gemcutter::HOST_DISPLAY %>.

          Name: <%= @api_key.name %>
          - Scope: <%= @api_key.enabled_scopes.join(", ") %> + Scope: <%= @api_key.scopes.to_sentence %>
          Created at: <%= @api_key.created_at.to_formatted_s(:rfc822) %> + <% if @api_key.oidc_id_token.present? %> +
          + <%= ApiKey.human_attribute_name(:oidc_api_key_role) %>: <%= link_to(@api_key.oidc_api_key_role.name, profile_oidc_api_key_role_url(@api_key.oidc_api_key_role.token), target: :_blank) %> + <% end %> + <% if @api_key.expires_at.present? %> +
          + <%= ApiKey.human_attribute_name(:expires_at) %>: <%= @api_key.expires_at.to_formatted_s(:rfc822) %> + <% end %> +


          <% if @api_key.name == "legacy-key" %> diff --git a/app/views/mailer/api_key_revoked.html.erb b/app/views/mailer/api_key_revoked.html.erb new file mode 100644 index 00000000000..85f3513eea2 --- /dev/null +++ b/app/views/mailer/api_key_revoked.html.erb @@ -0,0 +1,37 @@ +<% @title = "API KEY REVOKED" %> +<% @sub_title = "Hi #{@user.handle}" %> + + + + + + + + +
          +
           
          + +
          +

          + One of your API keys was revoked on <%= Gemcutter::HOST_DISPLAY %>. +

          +

          + Name: <%= @api_key_name %> +
          + Scope: <%= @enabled_scopes %> +

          +
          +

          + This API key was detected in a public repository by GitHub Secret Scanning. As a result we consider it compromised and have automatically revoked it to prevent any unauthorized access to your account. +

          + + + <%= render "compromised_instructions" %> +
          + +
           
          + +
           
          + +
          + diff --git a/app/views/mailer/deletion_complete.html.erb b/app/views/mailer/deletion_complete.html.erb index 7e248b865b9..82a8f6747b9 100644 --- a/app/views/mailer/deletion_complete.html.erb +++ b/app/views/mailer/deletion_complete.html.erb @@ -11,7 +11,7 @@
           
          - <%= t('.body_html', sign_up: link_to(t('sign_up'), sign_up_url)) %>
          + <%= t('.body_html', sign_up: link_to(t('sign_up'), sign_up_url), host: Gemcutter::HOST_DISPLAY) %>
           
          diff --git a/app/views/mailer/deletion_failed.html.erb b/app/views/mailer/deletion_failed.html.erb index abc71ae8c71..2ccdc415343 100644 --- a/app/views/mailer/deletion_failed.html.erb +++ b/app/views/mailer/deletion_failed.html.erb @@ -11,7 +11,7 @@
           
          - <%= t('.body_html', contact: link_to('contact', "mailto:support@rubygems.org")) %>
          + <%= t('.body_html', contact: link_to('contact', "mailto:support@rubygems.org"), host: Gemcutter::HOST_DISPLAY) %>
           
          diff --git a/app/views/mailer/email_confirmation.html.erb b/app/views/mailer/email_confirmation.html.erb index e0e430811a7..149d324e647 100644 --- a/app/views/mailer/email_confirmation.html.erb +++ b/app/views/mailer/email_confirmation.html.erb @@ -60,7 +60,7 @@ diff --git a/app/views/mailer/email_confirmation.text.erb b/app/views/mailer/email_confirmation.text.erb new file mode 100644 index 00000000000..5d13c3965f4 --- /dev/null +++ b/app/views/mailer/email_confirmation.text.erb @@ -0,0 +1,4 @@ +Hi <%= @user.handle %> + +<%= t("mailer.email_confirmation.welcome_message") %> +<%= update_email_confirmations_url(token: @user.confirmation_token.html_safe) %> diff --git a/app/views/mailer/email_reset.erb b/app/views/mailer/email_reset.erb index 663b092a1e0..a0bc3a1e076 100644 --- a/app/views/mailer/email_reset.erb +++ b/app/views/mailer/email_reset.erb @@ -11,7 +11,7 @@
           
          - <%= t('.visit_link_instructions') %>
          + <%= t('.visit_link_instructions', host: Gemcutter::HOST_DISPLAY) %>
           
          @@ -29,7 +29,7 @@ diff --git a/app/views/mailer/email_reset.text.erb b/app/views/mailer/email_reset.text.erb new file mode 100644 index 00000000000..7123117b792 --- /dev/null +++ b/app/views/mailer/email_reset.text.erb @@ -0,0 +1,4 @@ +<%= @sub_title = t(".subtitle", handle: @user.handle) %> + +<%= t('.visit_link_instructions', host: Gemcutter::HOST_DISPLAY) %> +<%= update_email_confirmations_url(token: @user.confirmation_token.html_safe) %> diff --git a/app/views/mailer/gem_pushed.html.erb b/app/views/mailer/gem_pushed.html.erb index 3562a63ed3b..b58b344933d 100644 --- a/app/views/mailer/gem_pushed.html.erb +++ b/app/views/mailer/gem_pushed.html.erb @@ -13,13 +13,24 @@ A gem you have push access to has recently released a new version.

          - Gem: <%= link_to @version.to_title, rubygem_version_url(@version.rubygem, @version.slug), target: "_blank" %> -
          - Pushed by user: - - <%= link_to @pushed_by_user.handle, profile_url(@pushed_by_user.display_id), target: "_blank" %> <%= mail_to(@pushed_by_user.email) unless @pushed_by_user.hide_email? %> - + Gem: <%= link_to @version.to_title, rubygem_version_url(@version.rubygem.slug, @version.slug), target: "_blank" %>
          + <% if @pushed_by_user %> + <% if @pushed_by_user.is_a?(User) %> + Pushed by user: + + <%= link_to @pushed_by_user.handle, profile_url(@pushed_by_user.display_id), target: "_blank" %> <%= mail_to(@pushed_by_user.email) if @pushed_by_user.public_email? %> + + <% else %> + Pushed by trusted publisher: + + <%= link_to @pushed_by_user.name, rubygem_trusted_publishers_url(@version.rubygem.slug), target: "_blank" %> + + <% end %> +
          + <% else %> + Unknown
          + <% end %> Pushed at: <%= @version.created_at.to_formatted_s(:rfc822) %>


          diff --git a/app/views/mailer/gem_trusted_publisher_added.html.erb b/app/views/mailer/gem_trusted_publisher_added.html.erb new file mode 100644 index 00000000000..1d844c4782e --- /dev/null +++ b/app/views/mailer/gem_trusted_publisher_added.html.erb @@ -0,0 +1,53 @@ +<% @title = t(".title") %> +<% @sub_title = @rubygem_trusted_publisher.rubygem.name %> + + + + + + + + +
          +
           
          + +
          +

          + A gem you have push access to has recently added a new trusted publisher. +

          +

          + Gem: <%= link_to @rubygem_trusted_publisher.rubygem.name, rubygem_url(@rubygem_trusted_publisher.rubygem.slug), target: "_blank" %> +
          + Trusted publisher: <%= link_to @rubygem_trusted_publisher.trusted_publisher.name, rubygem_trusted_publishers_url(@rubygem_trusted_publisher.rubygem.slug), target: "_blank" %> +
          + Added by: + + <%= link_to @created_by_user.display_handle, profile_url(@created_by_user.display_id), target: "_blank" %> <%= mail_to(@created_by_user.email) if @created_by_user.public_email? %> + +
          + Added at: <%= @rubygem_trusted_publisher.created_at.to_formatted_s(:rfc822) %> +

          +
          +

          If this new trusted publisher is expected, you do not need to take further action.

          +

          + Only if this change is unexpected + please take immediate steps to secure your account and gems: +

          + + <%= render "compromised_instructions" do %> +
        • Remove the trusted publisher reported in this email
        • + <% end %> + +

          + + To stop receiving these messages, update your <%= link_to("email notification settings", notifier_url) %>. + +

          +
          + +
           
          + +
           
          + +
          + diff --git a/app/views/mailer/gem_yanked.html.erb b/app/views/mailer/gem_yanked.html.erb index 83ad6719966..362a309b6b0 100644 --- a/app/views/mailer/gem_yanked.html.erb +++ b/app/views/mailer/gem_yanked.html.erb @@ -10,14 +10,14 @@

          - A version of a gem you are an owner of was recently yanked from RubyGems.org. + A version of a gem you are an owner of was recently yanked from <%= Gemcutter::HOST_DISPLAY %>.

          - Gem: <%= link_to @version.to_title, rubygem_version_url(@version.rubygem, @version.slug), target: "_blank" %> + Gem: <%= link_to @version.to_title, rubygem_version_url(@version.rubygem.slug, @version.slug), target: "_blank" %>
          Yanked by user: - <%= link_to @yanked_by_user.handle, profile_url(@yanked_by_user.display_id), target: "_blank" %> <%= mail_to(@yanked_by_user.email) unless @yanked_by_user.hide_email? %> + <%= link_to @yanked_by_user.handle, profile_url(@yanked_by_user.display_id), target: "_blank" %> <%= mail_to(@yanked_by_user.email) if @yanked_by_user.public_email? %>
          Yanked at: <%= @version.yanked_at.to_formatted_s(:rfc822) %> diff --git a/app/views/mailer/honeycomb_reset_api_key.erb b/app/views/mailer/honeycomb_reset_api_key.erb index a9b83a80233..5334d82f6f6 100644 --- a/app/views/mailer/honeycomb_reset_api_key.erb +++ b/app/views/mailer/honeycomb_reset_api_key.erb @@ -16,12 +16,12 @@ We do not have any record of your key being abused. However, we have reset your key as a precautionary step. To be completely sure, please double-check any gems published by your RubyGems.org account between 6 November 2018 and 19 July 2019. - Note that if you had enabled MFA for <%= link_to("UI and API", "https://guides.rubygems.org/setting-up-multifactor-authentication/#authentication-levels") %> + Note that if you had enabled multi-factor authentication (MFA) for <%= link_to("UI and API", "https://guides.rubygems.org/setting-up-multifactor-authentication/#authentication-levels") %> gem push, yank, owner (-a/r) operations were not compromised. For complete information about the issue, please <%= link_to("read our blog post", "https://blog.rubygems.org") %>.

          - You can find your new API key on <%= link_to("your RubyGems.org profile page", "https://rubygems.org/profile/edit") %>. You will also need to run gem signin again before you can push new gems. + You can find your new API key on <%= link_to("your RubyGems.org profile page", edit_profile_url(host: Gemcutter::HOST)) %>. You will also need to run gem signin again before you can push new gems.

          diff --git a/app/views/mailer/mfa_notification.html.erb b/app/views/mailer/mfa_notification.html.erb index a0e01dcd4f2..40e7944fba2 100644 --- a/app/views/mailer/mfa_notification.html.erb +++ b/app/views/mailer/mfa_notification.html.erb @@ -1,4 +1,4 @@ -<% @title = "Please enable MFA" %> +<% @title = "Please enable multi-factor authentication (MFA)" %> <% @sub_title = "Hi #{@user.handle}" %> @@ -11,12 +11,12 @@
           
          - We care about keeping your RubyGems.org account secure and recommend enabling multifactor authentication for both UI and API. - Setting up MFA only takes a few minutes and goes a long way towards protecting your account and the Ruby ecosystem. + We care about keeping your RubyGems.org account secure and recommend enabling multi-factor authentication for both UI and API. + Setting up multi-factor authentication (MFA) only takes a few minutes and goes a long way towards protecting your account and the Ruby ecosystem.
          Once enabled, we will ask you for an OTP for actions like sign in and gem push.
          - Please click on the Enable MFA button below to register a new device. You may be asked to first log in to your account. You can also find the same link on your <%= link_to("Edit Profile", "https://rubygems.org/profile/edit") %> page. + Please click on the Enable Multi-factor Authentication (MFA) button below to register a new device. You may be asked to first log in to your account. You can also find the same link on your <%= link_to("Edit Profile", edit_profile_url(host: Gemcutter::HOST)) %> page.
           
          @@ -34,7 +34,7 @@ @@ -50,7 +50,7 @@
           
          - Check our guides for more details on <%= link_to("setting up MFA", "https://guides.rubygems.org/setting-up-multifactor-authentication/") %> and <%= link_to("using MFA with command line", "https://guides.rubygems.org/using-mfa-in-command-line/") %>. + Check our guides for more details on <%= link_to("setting up multi-factor authentication (MFA)", "https://guides.rubygems.org/setting-up-multifactor-authentication/") %> and <%= link_to("using multi-factor authentication (MFA) with command line", "https://guides.rubygems.org/using-mfa-in-command-line/") %>.
           
          diff --git a/app/views/mailer/mfa_recommendation_announcement.html.erb b/app/views/mailer/mfa_recommendation_announcement.html.erb new file mode 100644 index 00000000000..6ae5c735617 --- /dev/null +++ b/app/views/mailer/mfa_recommendation_announcement.html.erb @@ -0,0 +1,137 @@ +<% @title = "Enable multi-factor authentication (MFA) on your RubyGems account" %> +<% @sub_title = "👋 Hi #{@user.handle}" %> + + + + + + + + +
          +
           
          + +
           
          + +
          +

          + Recently, we've announced our security-focused ambitions to the community. + Attacks on the software supply chain are increasing and the second most common attack is account takeovers. +

          +
          + +

          + On August 15, 2022, we will begin requiring maintainers of packages with more than 180 million downloads to have multi-factor authentication (MFA) enabled. + If maintainers don't have MFA enabled by this date, a number of operations will be restricted. + These restrictions include privileged operations (ie. push, yank, add/remove owners). + Furthermore, we are officially recommending that maintainers of packages with more than 165 million downloads enable MFA. + This recommendation will also be shown by the gem CLI. +

          +
          + + <% if @user.mfa_disabled? %> +

          + We're notifying you of this policy because you maintain at least one gem that has more than 165 million downloads. + So, we'd like to ask you to please enable multi-factor authentication on your RubyGems account 🙏. +

          +
          + +

          + We recommend setting the + MFA level + to UI and API. However, UI and gem signin is acceptable too. +

          +
          + <% elsif @user.mfa_ui_only? %> +

          + We're notifying you of this policy because you maintain at least one gem that has more than 165 million downloads. + Fortunately, you've already taken the first step! But, there's opportunity to improve. + Please upgrade your RubyGems account to a stronger MFA level 🙏. +

          +
          + +

          + We recommend setting the + MFA level + to UI and API. However, UI and gem signin is acceptable too. +

          +
          + <% else %> +

          + We're notifying you of this policy because you maintain at least one gem that has more than 165 million downloads. + Fortunately, you've already enabled MFA. Kudos to you 🥳 🙌! +

          +
          + <% end %> + +

          + For more information, check out our announcement. +

          +
          + +

          Thank you for making the RubyGems ecosystem more secure.

          +
          + +
           
          + + <% if @user.mfa_disabled? || @user.mfa_ui_only? %> + + + + + + +
          + + + + +
          + + + + + + +
          + + + + +
           
          +
          + +
          +
          +
          + + <% end %> + +
           
          + + + + + + +
           
          + +
           
          + +
          + Check our guides for more details on <%= link_to("setting up multi-factor authentication (MFA)", "https://guides.rubygems.org/setting-up-multifactor-authentication/") %> and <%= link_to("using multi-factor authentication (MFA) with command line", "https://guides.rubygems.org/using-mfa-in-command-line/") %>. +
          + +
           
          + +
           
          + +
          + diff --git a/app/views/mailer/mfa_recommendation_announcement.text.erb b/app/views/mailer/mfa_recommendation_announcement.text.erb new file mode 100644 index 00000000000..639f85c9abe --- /dev/null +++ b/app/views/mailer/mfa_recommendation_announcement.text.erb @@ -0,0 +1,28 @@ +👋 Hi <%= @user.handle %>, + +Recently, we've announced our security-focused ambitions to the community. Attacks on the software supply chain are increasing and the second most common attack is account takeovers. + +On August 15, 2022, we will begin requiring maintainers of packages with more than 180 million downloads to have multi-factor authentication (MFA) enabled. +If maintainers don't have MFA enabled by this date, a number of operations will be restricted. +These restrictions include privileged operations (ie. push, yank, add/remove owners) (https://guides.rubygems.org/mfa-requirement-opt-in/#privileged-operations). +Furthermore, we are officially recommending that maintainers of packages with more than 165 million downloads enable MFA. This recommendation will also be shown by the gem CLI. + +<% if @user.mfa_disabled? %> +We're notifying you of this policy because you maintain at least one gem that has more than 165 million downloads. +So, we'd like to ask you to please enable multi-factor authentication on your RubyGems account 🙏. + +We recommend setting the MFA level to "UI and API". However, "UI and gem signin" is acceptable too (https://guides.rubygems.org/setting-up-multifactor-authentication/#authentication-levels). +<% elsif @user.mfa_ui_only? %> +We're notifying you of this policy because you maintain at least one gem that has more than 165 million downloads. +Fortunately, you've already taken the first step! But, there's opportunity to improve. +Please upgrade your RubyGems account to a stronger MFA level 🙏. + +We recommend setting the MFA level to "UI and API". However, "UI and gem signin" is acceptable too (https://guides.rubygems.org/setting-up-multifactor-authentication/#authentication-levels). +<% else %> +We're notifying you of this policy because you maintain at least one gem that has more than 165 million downloads. +Fortunately, you've already enabled MFA. Kudos to you 🥳 🙌! +<% end %> + +For more information, check out our announcement (https://blog.rubygems.org/2022/06/13/making-packages-more-secure.html). + +❤️ Thank you for making the RubyGems ecosystem more secure diff --git a/app/views/mailer/mfa_required_popular_gems_announcement.html.erb b/app/views/mailer/mfa_required_popular_gems_announcement.html.erb new file mode 100644 index 00000000000..6d67be92710 --- /dev/null +++ b/app/views/mailer/mfa_required_popular_gems_announcement.html.erb @@ -0,0 +1,124 @@ +<% @title = @heading %> +<% @sub_title = "👋 Hi #{@user.handle}" %> + + + + + + + + +
          +
           
          + +
           
          + +
          +

          + Effective today, multi-factor authentication (MFA) is required on your RubyGems account. +

          +
          + + <% if @user.mfa_disabled? %> +

          + A number of operations are now restricted until you enable MFA. + These restrictions include editing profile pages on the web, signing in on the command line, and performing privileged actions (ie. push, yank, add/remove owners). +

          +
          + +

          + To re-enable these operations for your account, please enable multi-factor authentication 🙏. +

          +
          + <% elsif @user.mfa_ui_only? %> +

          + Your current MFA level is no longer recommended. In fact, it's deprecated. + You need to upgrade your RubyGems account to a stronger MFA level 🙏. +

          +
          + +

          + We recommend setting the + MFA level + to UI and API. However, UI and gem signin is acceptable too. +

          +
          + +

          + A number of operations are restricted until you upgrade your MFA setting. + These restrictions include editing profile pages on the web, signing in on the command line, and performing privileged actions (ie. push, yank, add/remove owners). +

          +
          + <% end %> + +

          + For more information, check out our announcement. +

          +
          + +

          Thank you for making the RubyGems ecosystem more secure.

          +
          + +
           
          + + <% if @user.mfa_disabled? || @user.mfa_ui_only? %> + + + + + + +
          + + + + +
          + + + + + + +
          + + + + +
           
          +
          + +
          +
          +
          + + <% end %> + +
           
          + + + + + + +
           
          + +
           
          + +
          + Check our guides for more details on <%= link_to("setting up multi-factor authentication (MFA)", "https://guides.rubygems.org/setting-up-multifactor-authentication/") %> and <%= link_to("using multi-factor authentication (MFA) with command line", "https://guides.rubygems.org/using-mfa-in-command-line/") %>. +
          + +
           
          + +
           
          + +
          + diff --git a/app/views/mailer/mfa_required_popular_gems_announcement.text.erb b/app/views/mailer/mfa_required_popular_gems_announcement.text.erb new file mode 100644 index 00000000000..811d68792b5 --- /dev/null +++ b/app/views/mailer/mfa_required_popular_gems_announcement.text.erb @@ -0,0 +1,24 @@ +👋 Hi <%= @user.handle %>, + +Effective today, multi-factor authentication (MFA) is required on your RubyGems account. + +<% if @user.mfa_disabled? %> +A number of operations are now restricted until you enable MFA. + +These restrictions include editing profile pages on the web, signing in on the command line, and performing privileged actions (ie. push, yank, add/remove owners) (https://guides.rubygems.org/mfa-requirement-opt-in/#privileged-operations). + +To re-enable these operations for your account, please enable multi-factor authentication 🙏. +<% elsif @user.mfa_ui_only? %> +Your current MFA level is no longer recommended. In fact, it's deprecated. +You need to upgrade your RubyGems account to a stronger MFA level 🙏. + +We recommend setting the MFA level to "UI and API". However, "UI and gem signin" is acceptable too (https://guides.rubygems.org/setting-up-multifactor-authentication/#authentication-levels). + +A number of operations are restricted until you upgrade your MFA setting. + +These restrictions include editing profile pages on the web, signing in on the command line, and performing privileged actions (ie. push, yank, add/remove owners) (https://guides.rubygems.org/mfa-requirement-opt-in/#privileged-operations). +<% end %> + +For more information, check out our announcement (https://blog.rubygems.org/2022/08/15/requiring-mfa-on-popular-gems.html). + +❤️ Thank you for making the RubyGems ecosystem more secure diff --git a/app/views/mailer/mfa_required_soon_announcement.html.erb b/app/views/mailer/mfa_required_soon_announcement.html.erb new file mode 100644 index 00000000000..e8c1c21a8d3 --- /dev/null +++ b/app/views/mailer/mfa_required_soon_announcement.html.erb @@ -0,0 +1,119 @@ +<% @title = @heading %> +<% @sub_title = "👋 Hi #{@user.handle}" %> + + + + + + + + +
          +
           
          + +
           
          + +
          +

          + Recently, we've announced our security-focused ambitions to the community. +

          +
          + +

          + Next week, on August 15, 2022, we will begin requiring maintainers like yourself, who are owners of packages with more than 180 million downloads, to have multi-factor authentication (MFA) enabled. +

          +
          + +

          + If you don't have MFA enabled by this date, a number of operations will be restricted. + These restrictions include editing profile pages on the web, signing in on the command line, and performing privileged actions (ie. push, yank, add/remove owners). +

          +
          + + <% if @user.mfa_disabled? %> +

          + To avoid any disruptions with your RubyGems account, please enable multi-factor authentication 🙏. +

          +
          + <% elsif @user.mfa_ui_only? %> +

          + We're notifying you of this policy because you have MFA enabled only for the UI. + Please upgrade your RubyGems account to a stronger MFA level 🙏. +

          +
          + +

          + We recommend setting the + MFA level + to UI and API. However, UI and gem signin is acceptable too. +

          +
          + <% end %> +
          + +

          Thank you for making the RubyGems ecosystem more secure.

          +
          + +
           
          + + <% if @user.mfa_disabled? || @user.mfa_ui_only? %> + + + + + + +
          + + + + +
          + + + + + + +
          + + + + +
           
          +
          + +
          +
          +
          + + <% end %> + +
           
          + + + + + + +
           
          + +
           
          + +
          + Check our guides for more details on <%= link_to("setting up multi-factor authentication (MFA)", "https://guides.rubygems.org/setting-up-multifactor-authentication/") %> and <%= link_to("using multi-factor authentication (MFA) with command line", "https://guides.rubygems.org/using-mfa-in-command-line/") %>. +
          + +
           
          + +
           
          + +
          + diff --git a/app/views/mailer/mfa_required_soon_announcement.text.erb b/app/views/mailer/mfa_required_soon_announcement.text.erb new file mode 100644 index 00000000000..95064aa0e55 --- /dev/null +++ b/app/views/mailer/mfa_required_soon_announcement.text.erb @@ -0,0 +1,19 @@ +👋 Hi <%= @user.handle %>, + +Recently, we've announced our security-focused ambitions to the community (https://blog.rubygems.org/2022/06/13/making-packages-more-secure.html). + +Next week, on August 15, 2022, we will begin requiring maintainers like yourself, who are owners of packages with more than 180 million downloads, to have multi-factor authentication (MFA) enabled. + +If you don't have MFA enabled by this date, a number of operations will be restricted. +These restrictions include editing profile pages on the web, signing in on the command line, and performing privileged actions (ie. push, yank, add/remove owners) (https://guides.rubygems.org/mfa-requirement-opt-in/#privileged-operations). + +<% if @user.mfa_disabled? %> +To avoid any disruptions with your RubyGems account, please enable multi-factor authentication 🙏. +<% elsif @user.mfa_ui_only? %> +We're notifying you of this policy because you have MFA enabled only for the UI. +Please upgrade your RubyGems account to a stronger MFA level 🙏. + +We recommend setting the MFA level to "UI and API". However, "UI and gem signin" is acceptable too (https://guides.rubygems.org/setting-up-multifactor-authentication/#authentication-levels). +<% end %> + +❤️ Thank you for making the RubyGems ecosystem more secure diff --git a/app/views/mailer/public_gem_reset_api_key.erb b/app/views/mailer/public_gem_reset_api_key.erb index 17aa1bb3580..aa18a9a843c 100644 --- a/app/views/mailer/public_gem_reset_api_key.erb +++ b/app/views/mailer/public_gem_reset_api_key.erb @@ -14,7 +14,7 @@ Please check the gems you own to ensure that they were not modified without your permission while your API key was public.

          - You can find your new API key on <%= link_to("your RubyGems.org profile page", "https://rubygems.org/profile/edit") %>. You will also need to run gem signin again before you can push new gems. + You can find your new API key on <%= link_to("your RubyGems.org profile page", edit_profile_url(host: Gemcutter::HOST)) %>. You will also need to run gem signin again before you can push new gems.

          diff --git a/app/views/mailer/totp_disabled.html.erb b/app/views/mailer/totp_disabled.html.erb new file mode 100644 index 00000000000..0433a2604c3 --- /dev/null +++ b/app/views/mailer/totp_disabled.html.erb @@ -0,0 +1,35 @@ +<% @title = t("mailer.totp_disabled.title") %> +<% @sub_title = t("mailer.totp_disabled.subtitle", handle: @user.handle) %> + + + + + + + +
          +
           
          + +
          +

          + An authentication app has been disabled for your account on RubyGems.org. +

          +
           
          +

          + Disabled at: <%= @disabled_at.to_formatted_s(:rfc822) %> +

          +
           
          + +

          If you disabled an authentication app on your account, no further action is required.

          +

          + Only if this change to your settings is unexpected + please take immediate steps to secure your account and gems: +

          + + <%= render "compromised_instructions" %> +
          + +
           
          + +
           
          +
          diff --git a/app/views/mailer/totp_enabled.html.erb b/app/views/mailer/totp_enabled.html.erb new file mode 100644 index 00000000000..50807687604 --- /dev/null +++ b/app/views/mailer/totp_enabled.html.erb @@ -0,0 +1,35 @@ +<% @title = t("mailer.totp_enabled.title") %> +<% @sub_title = t("mailer.totp_enabled.subtitle", handle: @user.handle) %> + + + + + + + +
          +
           
          + +
          +

          + An authentication app has been enabled for your account on RubyGems.org. +

          +
           
          +

          + Enabled at: <%= @enabled_at.to_formatted_s(:rfc822) %> +

          +
           
          + +

          If you enabled an authentication app on your account, no further action is required. Please keep your recovery codes in a secure location.

          +

          + Only if this change to your settings is unexpected + please take immediate steps to secure your account and gems: +

          + + <%= render "compromised_instructions" %> +
          + +
           
          + +
           
          +
          diff --git a/app/views/mailer/webauthn_credential_created.html.erb b/app/views/mailer/webauthn_credential_created.html.erb new file mode 100644 index 00000000000..669fab2d1d3 --- /dev/null +++ b/app/views/mailer/webauthn_credential_created.html.erb @@ -0,0 +1,37 @@ +<% @title = t("mailer.webauthn_credential_created.title") %> +<% @sub_title = t("mailer.webauthn_credential_created.subtitle", handle: @webauthn_credential.user.handle) %> + + + + + + + +
          +
           
          + +
          +

          + A new security device was added to your account on RubyGems.org. +

          +
           
          +

          + Name: <%= @webauthn_credential.nickname %> +
          + Created at: <%= @webauthn_credential.created_at.to_formatted_s(:rfc822) %> +

          +
           
          + +

          If this device creation is expected, you do not need to take further action.

          +

          + Only if this change to your settings is unexpected + please take immediate steps to secure your account and gems: +

          + + <%= render "compromised_instructions" %> +
          + +
           
          + +
           
          +
          diff --git a/app/views/mailer/webauthn_credential_created.text.erb b/app/views/mailer/webauthn_credential_created.text.erb new file mode 100644 index 00000000000..04b985c9575 --- /dev/null +++ b/app/views/mailer/webauthn_credential_created.text.erb @@ -0,0 +1,11 @@ +<%= t("mailer.webauthn_credential_created.subtitle", handle: @webauthn_credential.user.handle) %> + +A new security device was added to your account on RubyGems.org. + +Name: <%= @webauthn_credential.nickname %> +Created at: <%= @webauthn_credential.created_at.to_formatted_s(:rfc822) %> + +If this device creation is expected, you do not need to take further action. +Only if this is unexpected behavior, please take immediate steps to secure your account and gems: + +<%= render "compromised_instructions" %> diff --git a/app/views/mailer/webauthn_credential_removed.html.erb b/app/views/mailer/webauthn_credential_removed.html.erb new file mode 100644 index 00000000000..46e94600769 --- /dev/null +++ b/app/views/mailer/webauthn_credential_removed.html.erb @@ -0,0 +1,37 @@ +<% @title = t("mailer.webauthn_credential_removed.title") %> +<% @sub_title = t("mailer.webauthn_credential_removed.subtitle", handle: @user.handle) %> + + + + + + + +
          +
           
          + +
          +

          + A security device was removed from your account on RubyGems.org. +

          +
           
          +

          + Name: <%= @nickname %> +
          + Deleted at: <%= @deleted_at.to_formatted_s(:rfc822) %> +

          +
           
          + +

          If this device deletion was expected, you do not need to take further action.

          +

          + Only if this change to your settings is unexpected + please take immediate steps to secure your account and gems: +

          + + <%= render "compromised_instructions" %> +
          + +
           
          + +
           
          +
          diff --git a/app/views/mailer/webauthn_credential_removed.text.erb b/app/views/mailer/webauthn_credential_removed.text.erb new file mode 100644 index 00000000000..bd61c72dd26 --- /dev/null +++ b/app/views/mailer/webauthn_credential_removed.text.erb @@ -0,0 +1,11 @@ +<%= t("mailer.webauthn_credential_removed.subtitle", handle: @user.handle) %> + +A new security device was removed from your account on RubyGems.org. + +Name: <%= @nickname %> +Deleted at: <%= @deleted_at.to_formatted_s(:rfc822) %> + +If this device deletion was expected, you do not need to take further action. +Only if this is unexpected behavior, please take immediate steps to secure your account and gems: + +<%= render "compromised_instructions" %> diff --git a/app/views/multifactor_auths/_webauthn_prompt.html.erb b/app/views/multifactor_auths/_webauthn_prompt.html.erb new file mode 100644 index 00000000000..cd3b6ae7735 --- /dev/null +++ b/app/views/multifactor_auths/_webauthn_prompt.html.erb @@ -0,0 +1,13 @@ +
          +

          <%= t("multifactor_auths.prompt.security_device") %>

          +
          +

          <%= t("multifactor_auths.prompt.webauthn_credential_note") %>

          +
          + <%= form_tag @webauthn_verification_url, method: :post, class: "js-webauthn-session--form", data: { options: @webauthn_options.to_json } do %> +
          + + + <%= submit_tag t("multifactor_auths.prompt.sign_in_with_webauthn_credential"), class: 'js-webauthn-session--submit form__submit form__submit--no-hover' %> +
          + <% end %> +
          diff --git a/app/views/multifactor_auths/prompt.html.erb b/app/views/multifactor_auths/prompt.html.erb new file mode 100644 index 00000000000..2c8c89707e2 --- /dev/null +++ b/app/views/multifactor_auths/prompt.html.erb @@ -0,0 +1,39 @@ +<% @title = t("multifactor_authentication") %> + +
          + <% if @user.webauthn_enabled?%> + <%= render "multifactor_auths/webauthn_prompt" %> + <% end %> + + <% if @user.totp_enabled? || @user.webauthn_only_with_recovery? %> +
          + <% if @user.totp_enabled? %> +

          <%= t(".otp_code") %>

          + <% elsif @user.webauthn_only_with_recovery? %> +

          <%= t(".recovery_code") %>

          + <% end %> +
          +

          <%= t(".recovery_code_html") %>

          +
          + <%= form_tag @otp_verification_url, method: :post do %> +
          + <% if @user.totp_enabled? %> + <%= label_tag :otp, t(".otp_or_recovery"), class: 'form__label' %> + <%= text_field_tag :otp, '', class: 'form__input', autofocus: true, autocomplete: :off %> + <% elsif @user.webauthn_only_with_recovery? %> + <%= text_field_tag :otp, + '', + class: 'form__input', + autofocus: true, + autocomplete: :off, + aria: { label: t(".recovery_code") } + %> + <% end %> +
          +
          + <%= submit_tag t("authenticate"), data: { disable_with: t("form_disable_with")}, class: "form__submit" %> +
          + <% end %> +
          + <% end %> +
          diff --git a/app/views/multifactor_auths/recovery.html.erb b/app/views/multifactor_auths/recovery.html.erb index 25b47622c76..9a5fa4cbe06 100644 --- a/app/views/multifactor_auths/recovery.html.erb +++ b/app/views/multifactor_auths/recovery.html.erb @@ -1,11 +1,25 @@ -<% @title = t('.title') %> - -
          -
            - <% current_user.mfa_recovery_codes.each do |code| %> -
          • <%= code %>
          • - <% end %> -
          -

          <%= t '.note' %>

          -

          <%= link_to t('.continue'), edit_settings_path, class: "t-link--arrow" %>

          -
          +<% @title = t(".title") %> + +<%= tag.div( + class: "t-body", + data: { + controller: "clipboard", + clipboard_success_content_value: t('copied') + } +) do %> +

          <%= t ".note_html" %>

          + + <%# This tag contains the recovery codes and should not be a part of the form %> + <%= text_area_tag "source", "#{@mfa_recovery_codes.join("\n")}\n", class: "recovery-code-list", rows: @mfa_recovery_codes.size + 1, cols: @mfa_recovery_codes.first.length + 1, readonly: true, data: { clipboard_target: "source" } %> + + <%= form_tag(@continue_path, method: "get", class: "form", data: { controller: "recovery", recovery_confirm_value: t(".confirm_dialog"), action: "recovery#submit" }) do %> +

          <%= link_to t("copy_to_clipboard"), "#/", class: "t-link--bold recovery__copy__icon", data: { action: "clipboard#copy recovery#copy", clipboard_target: "button" } %>

          + +
          + <%= check_box_tag "checked", "ack", false, required: true, class: "form__checkbox__input" %> + <%= label_tag "checked", t(".saved"), class: "form__checkbox__label" %> +
          + + <%= button_tag t(".continue"), class: "form__submit form__submit--no-hover" %> + <% end %> +<% end %> diff --git a/app/views/news/_rubygem.html.erb b/app/views/news/_rubygem.html.erb index 481127eb9a5..638d59d8616 100644 --- a/app/views/news/_rubygem.html.erb +++ b/app/views/news/_rubygem.html.erb @@ -1,4 +1,4 @@ -<%= link_to rubygem_path(rubygem.name), class: 'gems__gem' do %> +<%= link_to rubygem_path(rubygem.slug), class: 'gems__gem' do %>

          <%= rubygem.name %> diff --git a/app/views/notifiers/show.html.erb b/app/views/notifiers/show.html.erb index 64a23d752f0..15922ae456c 100644 --- a/app/views/notifiers/show.html.erb +++ b/app/views/notifiers/show.html.erb @@ -32,6 +32,19 @@ <%= t('.off') %> <% end %>

          + +

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

          +
          + <%= label_tag do %> + <%= radio_button_tag "ownerships[#{ownership.id}][ownership_request]", :on, ownership.ownership_request_notifier? %> + <%= t('.on') %> (<%= t('.recommended') %>) + <% end %> +
          + <%= label_tag do %> + <%= radio_button_tag "ownerships[#{ownership.id}][ownership_request]", :off, !ownership.ownership_request_notifier? %> + <%= t('.off') %> + <% end %> +

          <% end %> <%= submit_tag t('.update'), class: 'form__submit' %> <% end %> diff --git a/app/views/oidc/access_policies/_access_policy.html.erb b/app/views/oidc/access_policies/_access_policy.html.erb new file mode 100644 index 00000000000..289e3478779 --- /dev/null +++ b/app/views/oidc/access_policies/_access_policy.html.erb @@ -0,0 +1,8 @@ +
          +
          +
          <%= access_policy.class.human_attribute_name :statements %>
          +
          + <%= render access_policy.statements %> +
          +
          +
          diff --git a/app/views/oidc/access_policy/statement/conditions/_condition.html.erb b/app/views/oidc/access_policy/statement/conditions/_condition.html.erb new file mode 100644 index 00000000000..6a450ff1c21 --- /dev/null +++ b/app/views/oidc/access_policy/statement/conditions/_condition.html.erb @@ -0,0 +1,3 @@ +
          + <%= condition.claim %> <%= condition.operator %> <%= condition.value %> +
          diff --git a/app/views/oidc/access_policy/statement/conditions/_fields.html.erb b/app/views/oidc/access_policy/statement/conditions/_fields.html.erb new file mode 100644 index 00000000000..c60c41648cb --- /dev/null +++ b/app/views/oidc/access_policy/statement/conditions/_fields.html.erb @@ -0,0 +1,24 @@ +
          +
          +
          + <%= f.label :claim, class: "form__label" %> + <%= f.text_field :claim, list: f.field_id(:claims_supported), class: "form__input", autocomplete: :off %> + <%= content_tag(:datalist, id: f.field_id(:claims_supported)) do %> + <% if claims_supported = @api_key_role&.provider&.configuration&.claims_supported.presence %> + <%= options_from_collection_for_select(claims_supported, :to_s, :to_s) %> + <% end %> + <% end %> +
          +
          + <%= f.label :operator, class: "form__label" %> +

          + <%= f.collection_select :operator, f.object.class::OPERATORS, :to_s, :titleize, class: "form__input form__select" %> +

          +
          + <%= f.label :value, class: "form__label" %> +

          + <%= f.text_field :value, class: "form__input", autocomplete: :off %> +

          + <%= f.button t("oidc.api_key_roles.form.remove_condition"), class: "form__submit form__remove_nested_button" %> +
          +
          diff --git a/app/views/oidc/access_policy/statements/_fields.html.erb b/app/views/oidc/access_policy/statements/_fields.html.erb new file mode 100644 index 00000000000..f18b88e9068 --- /dev/null +++ b/app/views/oidc/access_policy/statements/_fields.html.erb @@ -0,0 +1,32 @@ +
          +
          + <%= f.label :effect, class: "form__label" %> +

          + <%= f.collection_select :effect, f.object.class::EFFECTS, :to_s, :to_s, selected: :effect, class: "form__input form__select" %> +

          +
          + <%= f.label :principal, class: "form__label" %> + <%= f.fields_for :principal, f.object.principal do |f| %> +
          + <%= f.label :oidc, class: "form__label" %> + <%= f.text_field :oidc, class: "form__input", autocomplete: :off, list: f.field_id(:issuers) %> + <%= content_tag(:datalist, id: f.field_id(:issuers)) do %> + <%= options_from_collection_for_select(OIDC::Provider.limit(50).pluck(:issuer), :to_s, :to_s) %> + <% end %> +
          + <% end %> +
          +
          + <%= f.label :conditions, class: "form__label" %> + <%= f.button t("oidc.api_key_roles.form.add_condition"), class: "form__submit form__add_nested_button" %> + <%= f.fields_for :conditions, [OIDC::AccessPolicy::Statement::Condition.new], child_index: 'NEW_OBJECT' do |f| %> + + <% end %> + <%= f.fields_for :conditions do |f| %> + <%= render(partial: "oidc/access_policy/statement/conditions/fields", locals: { f: }) %> + <%end%> +
          + <%= f.button t("oidc.api_key_roles.form.remove_statement"), class: "form__submit form__remove_nested_button" %> +
          diff --git a/app/views/oidc/access_policy/statements/_statement.html.erb b/app/views/oidc/access_policy/statements/_statement.html.erb new file mode 100644 index 00000000000..34ac597bf74 --- /dev/null +++ b/app/views/oidc/access_policy/statements/_statement.html.erb @@ -0,0 +1,10 @@ +
          +
          +
          <%= statement.class.human_attribute_name(:effect) %>
          +
          <%= statement.effect %>
          +
          <%= statement.class.human_attribute_name(:principal) %>
          +
          <%= link_to statement.principal.oidc, statement.principal.oidc %>
          +
          <%= statement.class.human_attribute_name(:conditions) %>
          +
          <%= render statement.conditions %>
          +
          +
          diff --git a/app/views/oidc/api_key_permissions/_api_key_permissions.html.erb b/app/views/oidc/api_key_permissions/_api_key_permissions.html.erb new file mode 100644 index 00000000000..0d95336776f --- /dev/null +++ b/app/views/oidc/api_key_permissions/_api_key_permissions.html.erb @@ -0,0 +1,10 @@ +
          +
          +
          <%= api_key_permissions.class.human_attribute_name :scopes %>
          +
          <%= to_sentence api_key_permissions.scopes %>
          +
          <%= api_key_permissions.class.human_attribute_name :valid_for %>
          +
          <%= duration_string api_key_permissions.valid_for %>
          +
          <%= api_key_permissions.class.human_attribute_name :gems %>
          +
          <%= to_sentence(api_key_permissions.gems.presence&.map { |gem| link_to(gem, rubygem_path(gem)) } || [t("api_keys.all_gems")]) %>
          +
          +
          diff --git a/app/views/oidc/api_key_roles/_form.html.erb b/app/views/oidc/api_key_roles/_form.html.erb new file mode 100644 index 00000000000..f12a0b93410 --- /dev/null +++ b/app/views/oidc/api_key_roles/_form.html.erb @@ -0,0 +1,63 @@ +<% url_attrs = @api_key_role.persisted? ? {url: profile_oidc_api_key_role_path(@api_key_role.token)} : {} %> +<%= form_for [:profile, @api_key_role], **url_attrs do |f|%> + <%= error_messages_for @api_key_role %> + <%= f.label :name, class: "form__label" %> + <%= f.text_field :name, class: "form__input", autocomplete: :off %> +
          + <%= f.label :oidc_provider_id, class: "form__label" %> +

          + <%= f.collection_select :oidc_provider_id, OIDC::Provider.all, :id, :issuer, selected: :provider_id, class: "form__input form__select" %> +

          +
          + <%= f.label :api_key_permissions, class: "form__label" %> + <%= f.fields_for :api_key_permissions, f.object.api_key_permissions do |f|%> +
          +
          + <%= f.label :scopes, t("api_keys.index.scopes"), class: "form__label" %> +
          + <%= f.collection_check_boxes :scopes, ApiKey::API_SCOPES, :to_s, :to_s, include_hidden: false do |b| %> +
          + <%= + b.check_box(class: "form__checkbox__input") + + b.label(class: "form__checkbox__label") do + t("api_keys.index.#{b.value}") + end + %> +
          + <% end %> +
          +
          +
          + <%= f.label :valid_for, class: "form__label" %> + <%= f.text_field :valid_for, value: f.object.valid_for.iso8601, class: "form__input", list: f.field_id(:valid_for_suggestions), autocomplete: :off %> + <%= content_tag(:datalist, id: f.field_id(:valid_for_suggestions)) do %> + <%= options_from_collection_for_select([5.minutes, 15.minutes, 30.minutes, 1.hour, 6.hours, 1.day], :iso8601, :inspect) %> + <% end %> +
          +
          + <%= f.label :gems, t("api_keys.form.rubygem_scope"), class: "form__label" %> +

          <%= t("api_keys.form.rubygem_scope_info") %>

          + <%= f.collection_select :gems, current_user.rubygems.by_name, :name, :name, { include_blank: t("api_keys.all_gems"), include_hidden: false }, selected: :name, class: "form__input form__select", multiple: true %> +
          +
          + <% end%> +
          +
          + <%= f.label :access_policy, class: "form__label" %> + <%= f.fields_for :access_policy, f.object.access_policy do |f|%> +
          + <%= f.label :statements, class: "form__label" %> + <%= f.button t("oidc.api_key_roles.form.add_statement"), class: "form__submit form__add_nested_button" %> + <%= f.fields_for :statements, [OIDC::AccessPolicy::Statement.new(conditions: [OIDC::AccessPolicy::Statement::Condition.new])], child_index: 'NEW_OBJECT' do |f| %> + + <% end %> + <%= f.fields_for :statements, f.object.statements do |f| %> + <%= render(partial: "oidc/access_policy/statements/fields", locals: { f: }) %> + <% end %> +
          + <% end %> +
          + <%= f.submit class: "form__submit" %> +<% end %> diff --git a/app/views/oidc/api_key_roles/edit.html.erb b/app/views/oidc/api_key_roles/edit.html.erb new file mode 100644 index 00000000000..2e20afdc49b --- /dev/null +++ b/app/views/oidc/api_key_roles/edit.html.erb @@ -0,0 +1,4 @@ +<% @title = t(".edit_role") %> +
          + <%= render "form" %> +
          diff --git a/app/views/oidc/api_key_roles/github_actions_workflow_view.rb b/app/views/oidc/api_key_roles/github_actions_workflow_view.rb new file mode 100644 index 00000000000..c250501599c --- /dev/null +++ b/app/views/oidc/api_key_roles/github_actions_workflow_view.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +class OIDC::ApiKeyRoles::GitHubActionsWorkflowView < ApplicationView + include Phlex::Rails::Helpers::LinkTo + + attr_reader :api_key_role + + def initialize(api_key_role:) + @api_key_role = api_key_role + super() + end + + def view_template + self.title = t(".title") + + return if not_configured + + div(class: "t-body", data: { controller: "clipboard", clipboard_success_content_value: "✔" }) do + p do + t(".configured_for_html", link_html: + single_gem_role? ? helpers.link_to(gem_name, rubygem_path(gem_name)) : t(".a_gem")) + end + + p do + t(".to_automate_html", link_html: + single_gem_role? ? helpers.link_to(gem_name, rubygem_path(gem_name)) : t(".a_gem")) + end + + p { t(".instructions_html") } + + header(class: "gem__code__header") do + h3(class: "t-list__heading l-mb-0") { code { ".github/workflows/push_gem.yml" } } + button( + class: "gem__code__icon", + title: t("copy_to_clipboard"), + data: { action: "click->clipboard#copy", clipboard_target: "button" } + ) { "=" } + end + pre(class: "gem__code multiline") do + code(class: "multiline", id: "workflow_yaml", data: { clipboard_target: "source" }) do + plain workflow_yaml + end + end + end + end + + private + + def gem_name + single_gem_role? ? api_key_role.api_key_permissions.gems.first : "YOUR_GEM_NAME" + end + + def workflow_yaml + YAML.safe_dump({ + on: { push: { tags: ["v*"] } }, + name: "Push Gem", + jobs: { + push: { + "runs-on": "ubuntu-latest", + permissions: { + contents: "write", + "id-token": "write" + }, + steps: [ + { uses: "rubygems/configure-rubygems-credentials@main", + with: { "role-to-assume": api_key_role.token, audience: configured_audience, "gem-server": gem_server_url }.compact }, + { uses: "actions/checkout@v4" }, + { name: "Set remote URL", run: set_remote_url_run }, + { name: "Set up Ruby", uses: "ruby/setup-ruby@v1", with: { "bundler-cache": true, "ruby-version": "ruby" } }, + { name: "Release", run: "bundle exec rake release" }, + { name: "Wait for release to propagate", run: await_run } + ] + } + } + }.deep_stringify_keys) + end + + def set_remote_url_run + <<~BASH + # Attribute commits to the last committer on HEAD + git config --global user.email "$(git log -1 --pretty=format:'%ae')" + git config --global user.name "$(git log -1 --pretty=format:'%an')" + git remote set-url origin "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/$GITHUB_REPOSITORY" + BASH + end + + def await_run + <<~BASH + gem install rubygems-await + gem_tuple="$(ruby -rbundler/setup -rbundler -e ' + spec = Bundler.definition.specs.find {|s| s.name == ARGV[0] } + raise "No spec for \#{ARGV[0]}" unless spec + print [spec.name, spec.version, spec.platform].join(":") + ' #{gem_name.dump})" + gem await #{"--source #{gem_server_url.dump} " if gem_server_url}"${gem_tuple}" + BASH + end + + def not_configured + is_github = api_key_role.provider.github_actions? + is_push = api_key_role.api_key_permissions.scopes.include?("push_rubygem") + return if is_github && is_push + div(class: "t-body") do + p { t(".not_github") } unless is_github + p { t(".not_push") } unless is_push + end + true + end + + def configured_audience + auds = api_key_role.access_policy.statements.flat_map do |s| + next unless s.effect == "allow" + + s.conditions.flat_map do |c| + c.value if c.claim == "aud" + end + end + auds.compact! + auds.uniq! + + return unless auds.size == 1 + aud = auds.first + aud if aud != "rubygems.org" # default in action + end + + def gem_server_url + host = Gemcutter::HOST + return if host == "rubygems.org" # default in action + "https://#{host}" + end + + def single_gem_role? + api_key_role.api_key_permissions.gems&.size == 1 + end +end diff --git a/app/views/oidc/api_key_roles/index.html.erb b/app/views/oidc/api_key_roles/index.html.erb new file mode 100644 index 00000000000..a2ea9b7bea1 --- /dev/null +++ b/app/views/oidc/api_key_roles/index.html.erb @@ -0,0 +1,40 @@ +<% @title = t(".api_key_roles") %> +
          +

          + <%= button_to(t(".new_role"), new_profile_oidc_api_key_role_path, method: "get", class: "form__submit") %> +

          +
          +

          <%= page_entries_info(@api_key_roles) %>

          +
          + + + + + + + + + + <% @api_key_roles.each do |api_key_role| %> + + + + + + <% end %> + +
          + <%= OIDC::ApiKeyRole.human_attribute_name(:name) %> + + <%= OIDC::ApiKeyRole.human_attribute_name(:token) %> + + <%= OIDC::Provider.human_attribute_name(:issuer) %> +
          + <%= link_to api_key_role.name, profile_oidc_api_key_role_path(api_key_role.token) %> + + <%= api_key_role.token %> + + <%= link_to api_key_role.provider.issuer, profile_oidc_provider_path(api_key_role.provider) %> +
          + <%= paginate @api_key_roles %> +
          diff --git a/app/views/oidc/api_key_roles/new.html.erb b/app/views/oidc/api_key_roles/new.html.erb new file mode 100644 index 00000000000..cdcb6ade327 --- /dev/null +++ b/app/views/oidc/api_key_roles/new.html.erb @@ -0,0 +1,4 @@ +<% @title = t(".title") %> +
          + <%= render "form" %> +
          diff --git a/app/views/oidc/api_key_roles/show.html.erb b/app/views/oidc/api_key_roles/show.html.erb new file mode 100644 index 00000000000..aa7f645f094 --- /dev/null +++ b/app/views/oidc/api_key_roles/show.html.erb @@ -0,0 +1,41 @@ +<% @title = t(".api_key_role_name", name: @api_key_role.name) %> +
          + <% if @api_key_role.github_actions_push? && !@api_key_role.deleted_at? %> +

          <%= link_to t(".automate_gh_actions_publishing"), github_actions_workflow_profile_oidc_api_key_role_path(@api_key_role.token), class: "t-link t-underline" %> →

          + <% end %> + <% if @api_key_role.deleted_at? %> +

          + <%= t(".deleted_at_html", time_html: time_tag(@api_key_role.deleted_at, time_ago_in_words(@api_key_role.deleted_at))) %> +

          + <% end %> +

          <%= OIDC::ApiKeyRole.human_attribute_name(:token) %>

          +
          + <%= @api_key_role.token %> +
          +

          <%= OIDC::ApiKeyRole.human_attribute_name(:provider) %>

          +
          + <%= link_to t(".view_provider", issuer: @api_key_role.provider.issuer), profile_oidc_provider_path(@api_key_role.provider) %> → +
          +

          <%= OIDC::ApiKeyRole.human_attribute_name(:api_key_permissions) %>

          +
          + <%= render partial: "oidc/api_key_permissions/api_key_permissions", object: @api_key_role.api_key_permissions %> +
          +

          <%= OIDC::ApiKeyRole.human_attribute_name(:access_policy) %>

          +
          + <%= render partial: "oidc/access_policies/access_policy", object: @api_key_role.access_policy %> +
          + + <% unless @api_key_role.deleted_at? %> + <%= button_to t(".edit_role"), edit_profile_oidc_api_key_role_path(@api_key_role.token), method: "get", class: "form__submit" %> + <%= button_to t(".delete_role"), profile_oidc_api_key_role_path(@api_key_role.token), method: "delete", class: "form__submit", data: { confirm: t(".confirm_delete") } %> + <% end %> +

          <%= OIDC::ApiKeyRole.human_attribute_name(:id_tokens) %>

          +
          +
          +

          <%= page_entries_info(@id_tokens) %>

          +
          + <% if @id_tokens.present? %> + <%= render OIDC::IdToken::TableComponent.new(id_tokens: @id_tokens) %> + <% end %> +
          +
          diff --git a/app/views/oidc/id_tokens/index_view.rb b/app/views/oidc/id_tokens/index_view.rb new file mode 100644 index 00000000000..ce917687ebe --- /dev/null +++ b/app/views/oidc/id_tokens/index_view.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class OIDC::IdTokens::IndexView < ApplicationView + attr_reader :id_tokens + + def initialize(id_tokens:) + @id_tokens = id_tokens + super() + end + + def view_template + self.title = t(".title") + + div(class: "t-body") do + header(class: "gems__header push--s") do + p(class: "gems__meter l-mb-0") { plain helpers.page_entries_info(id_tokens) } + end + if id_tokens.present? + render OIDC::IdToken::TableComponent.new(id_tokens:) + plain helpers.paginate(id_tokens) + end + end + end +end diff --git a/app/views/oidc/id_tokens/show_view.rb b/app/views/oidc/id_tokens/show_view.rb new file mode 100644 index 00000000000..995222b359d --- /dev/null +++ b/app/views/oidc/id_tokens/show_view.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class OIDC::IdTokens::ShowView < ApplicationView + extend Dry::Initializer + include Phlex::Rails::Helpers::TimeTag + include Phlex::Rails::Helpers::LinkTo + + option :id_token + + def view_template # rubocop:disable Metrics/AbcSize + self.title = t(".title") + + div(class: "t-body") do + section(:created_at) { time_tag id_token.created_at } + section(:expires_at) { time_tag id_token.api_key.expires_at } + section(:jti) { code { id_token.jti } } + section(:api_key_role) { link_to id_token.api_key_role.name, profile_oidc_api_key_role_path(id_token.api_key_role.token) } + section(:provider) { link_to id_token.provider.issuer, profile_oidc_provider_path(id_token.provider) } + section(:claims) { render OIDC::IdToken::KeyValuePairsComponent.new(pairs: id_token.claims) } + section(:header) { render OIDC::IdToken::KeyValuePairsComponent.new(pairs: id_token.header) } + end + end + + private + + def section(header, &) + h3(class: "t-list__heading") { id_token.class.human_attribute_name(header) } + div(class: "push--s", &) + end +end diff --git a/app/views/oidc/pending_trusted_publishers/index_view.rb b/app/views/oidc/pending_trusted_publishers/index_view.rb new file mode 100644 index 00000000000..60343a55cd9 --- /dev/null +++ b/app/views/oidc/pending_trusted_publishers/index_view.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +class OIDC::PendingTrustedPublishers::IndexView < ApplicationView + include Phlex::Rails::Helpers::ButtonTo + include Phlex::Rails::Helpers::ContentFor + include Phlex::Rails::Helpers::DistanceOfTimeInWordsToNow + include Phlex::Rails::Helpers::LinkTo + extend Dry::Initializer + + option :trusted_publishers + + def view_template + title_content + + div(class: "tw-space-y-2 t-body") do + p do + t(".description_html") + end + + p do + button_to t(".create"), new_profile_oidc_pending_trusted_publisher_path, class: "form__submit", method: :get + end + + header(class: "gems__header push--s") do + p(class: "gems__meter l-mb-0") { plain helpers.page_entries_info(trusted_publishers) } + end + + div(class: "tw-divide-y") do + trusted_publishers.each do |pending_trusted_publisher| + trusted_publisher_section(pending_trusted_publisher) + end + end + + plain helpers.paginate(trusted_publishers) + end + end + + def title_content + self.title_for_header_only = t(".title") + content_for :title do + h1(class: "t-display page__heading page__heading-small") do + plain t(".title") + end + end + end + + def trusted_publisher_section(pending_trusted_publisher) + div(class: "tw-border-solid tw-my-4 tw-space-y-2 tw-flex tw-flex-col") do + div(class: "sm:tw-flex sm:tw-flex-row tw-gap-4 tw-mt-2") do + h3(class: "!tw-mb-0") { pending_trusted_publisher.rubygem_name } + button_to(t(".delete"), profile_oidc_pending_trusted_publisher_path(pending_trusted_publisher), + method: :delete, class: "form__submit form__submit--small") + end + + div(class: "sm:tw-flex sm:tw-flex-row tw-gap-4") do + p(class: "!tw-mb-0") { pending_trusted_publisher.trusted_publisher.class.publisher_name } + p(class: "!tw-mb-0") do + t(".valid_for_html", + time_html: helpers.time_tag(pending_trusted_publisher.expires_at, +distance_of_time_in_words_to_now(pending_trusted_publisher.expires_at))) + end + end + + render pending_trusted_publisher.trusted_publisher + end + end +end diff --git a/app/views/oidc/pending_trusted_publishers/new_view.rb b/app/views/oidc/pending_trusted_publishers/new_view.rb new file mode 100644 index 00000000000..e9485d61e4a --- /dev/null +++ b/app/views/oidc/pending_trusted_publishers/new_view.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class OIDC::PendingTrustedPublishers::NewView < ApplicationView + include Phlex::Rails::Helpers::LinkTo + include Phlex::Rails::Helpers::SelectTag + include Phlex::Rails::Helpers::OptionsForSelect + include Phlex::Rails::Helpers::FormWith + + extend Dry::Initializer + + option :pending_trusted_publisher + + def view_template + self.title = t(".title") + + div(class: "t-body") do + form_with( + model: pending_trusted_publisher, + url: profile_oidc_pending_trusted_publishers_path + ) do |f| + f.label :rubygem_name, class: "form__label" + f.text_field :rubygem_name, class: "form__input", autocomplete: :off + p(class: "form__field__instructions") { t("oidc.trusted_publisher.pending.rubygem_name_help_html") } + + f.label :trusted_publisher_type, class: "form__label" + f.select :trusted_publisher_type, OIDC::TrustedPublisher.all.map { |type| + [type.publisher_name, type.polymorphic_name] + }, {}, class: "form__input form__select" + + render OIDC::TrustedPublisher::GitHubAction::FormComponent.new( + github_action_form: f + ) + f.submit class: "form__submit" + end + end + end +end diff --git a/app/views/oidc/providers/index_view.rb b/app/views/oidc/providers/index_view.rb new file mode 100644 index 00000000000..1c40139c29c --- /dev/null +++ b/app/views/oidc/providers/index_view.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class OIDC::Providers::IndexView < ApplicationView + include Phlex::Rails::Helpers::LinkTo + + attr_reader :providers + + def initialize(providers:) + @providers = providers + super() + end + + def view_template + self.title = t(".title") + + div(class: "t-body") do + p do + t(".description_html") + end + hr + header(class: "gems__header push--s") do + p(class: "gems__meter l-mb-0") { plain helpers.page_entries_info(providers) } + end + ul do + providers.each do |provider| + li { link_to provider.issuer, profile_oidc_provider_path(provider) } + end + end + plain helpers.paginate(providers) + end + end +end diff --git a/app/views/oidc/providers/show_view.rb b/app/views/oidc/providers/show_view.rb new file mode 100644 index 00000000000..86d376e0409 --- /dev/null +++ b/app/views/oidc/providers/show_view.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +class OIDC::Providers::ShowView < ApplicationView + include Phlex::Rails::Helpers::LinkTo + + attr_reader :provider + + def initialize(provider:) + @provider = provider + super() + end + + def view_template + self.title = t(".title") + + div(class: "") do + dl(class: "t-body provider_attributes") do + supported_attrs.each do |attr| + val = provider.configuration.send(attr) + next if val.blank? + dt { provider.configuration.class.human_attribute_name(attr) } + dd do + attr.end_with?("s_supported") ? tags_attr(attr, val) : text_attr(attr, val) + end + end + end + + div(class: "t-body") do + hr + h3(class: "t-list__heading") { "Roles" } + + div(class: "") do + api_key_roles = helpers.current_user.oidc_api_key_roles.where(provider:).page(0).per(10) + header(class: "gems__header push--s") do + p(class: "gems__meter l-mb-0") { plain helpers.page_entries_info(api_key_roles) } + end + render OIDC::ApiKeyRole::TableComponent.new(api_key_roles:) if api_key_roles.present? + end + end + end + end + + def supported_attrs + (provider.configuration.required_attributes + provider.configuration.optional_attributes).map!(&:to_s) + end + + def tags_attr(_attr, val) + ul(class: "tag-list") do + val.each do |t| + li { code { t } } + end + end + end + + def text_attr(attr, val) + code do + case attr + when "issuer", /_(uri|endpoint)$/ + link_to(val, val) + else + val + end + end + end +end diff --git a/app/views/oidc/rubygem_trusted_publishers/concerns/title.rb b/app/views/oidc/rubygem_trusted_publishers/concerns/title.rb new file mode 100644 index 00000000000..56643847482 --- /dev/null +++ b/app/views/oidc/rubygem_trusted_publishers/concerns/title.rb @@ -0,0 +1,18 @@ +module OIDC::RubygemTrustedPublishers::Concerns::Title + extend ActiveSupport::Concern + + included do + def title_content + self.title_for_header_only = t(".title") + content_for :title do + h1(class: "t-display page__heading page__heading-small") do + plain t(".title") + + i(class: "page__subheading page__subheading--block") do + t(".subtitle_owner_html", gem_html: helpers.link_to(rubygem.name, rubygem_path(rubygem.slug), class: "t-link t-underline")) + end + end + end + end + end +end diff --git a/app/views/oidc/rubygem_trusted_publishers/index_view.rb b/app/views/oidc/rubygem_trusted_publishers/index_view.rb new file mode 100644 index 00000000000..3619836663f --- /dev/null +++ b/app/views/oidc/rubygem_trusted_publishers/index_view.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class OIDC::RubygemTrustedPublishers::IndexView < ApplicationView + include Phlex::Rails::Helpers::ButtonTo + include Phlex::Rails::Helpers::LinkTo + include Phlex::Rails::Helpers::ContentFor + include OIDC::RubygemTrustedPublishers::Concerns::Title + extend Dry::Initializer + + option :rubygem + option :trusted_publishers + + def view_template + title_content + + div(class: "tw-space-y-2 t-body") do + p do + t(".description_html") + end + + p do + button_to t(".create"), new_rubygem_trusted_publisher_path(rubygem.slug), class: "form__submit", method: :get + end + + header(class: "gems__header push--s") do + p(class: "gems__meter l-mb-0") { plain helpers.page_entries_info(trusted_publishers) } + end + + div(class: "tw-divide-y") do + trusted_publishers.each do |rubygem_trusted_publisher| + div(class: "tw-border-solid tw-my-4 tw-space-y-4 tw-flex tw-flex-col") do + div(class: "sm:tw-flex sm:tw-items-baseline tw-mt-4 tw-gap-2") do + h4 { rubygem_trusted_publisher.trusted_publisher.class.publisher_name } + button_to(t(".delete"), rubygem_trusted_publisher_path(rubygem.slug, rubygem_trusted_publisher), + method: :delete, class: "form__submit form__submit--small") + end + render rubygem_trusted_publisher.trusted_publisher + end + end + end + + plain helpers.paginate(trusted_publishers) + end + end +end diff --git a/app/views/oidc/rubygem_trusted_publishers/new_view.rb b/app/views/oidc/rubygem_trusted_publishers/new_view.rb new file mode 100644 index 00000000000..f4280aa771a --- /dev/null +++ b/app/views/oidc/rubygem_trusted_publishers/new_view.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class OIDC::RubygemTrustedPublishers::NewView < ApplicationView + include Phlex::Rails::Helpers::ContentFor + include Phlex::Rails::Helpers::FormWith + include Phlex::Rails::Helpers::LinkTo + include Phlex::Rails::Helpers::OptionsForSelect + include Phlex::Rails::Helpers::SelectTag + include OIDC::RubygemTrustedPublishers::Concerns::Title + + extend Dry::Initializer + + option :rubygem_trusted_publisher + + def view_template + title_content + + div(class: "t-body") do + form_with( + model: rubygem_trusted_publisher, + url: rubygem_trusted_publishers_path(rubygem_trusted_publisher.rubygem.slug) + ) do |f| + f.label :trusted_publisher_type, class: "form__label" + f.select :trusted_publisher_type, OIDC::TrustedPublisher.all.map { |type| + [type.publisher_name, type.polymorphic_name] + }, {}, class: "form__input form__select" + + render OIDC::TrustedPublisher::GitHubAction::FormComponent.new( + github_action_form: f + ) + f.submit class: "form__submit" + end + end + end + + delegate :rubygem, to: :rubygem_trusted_publisher +end diff --git a/app/views/oidc/trusted_publisher/github_actions/_github_action.html.erb b/app/views/oidc/trusted_publisher/github_actions/_github_action.html.erb new file mode 100644 index 00000000000..f54667d34a5 --- /dev/null +++ b/app/views/oidc/trusted_publisher/github_actions/_github_action.html.erb @@ -0,0 +1 @@ +<%= render OIDC::TrustedPublisher::GitHubAction::TableComponent.new(github_action:) %> \ No newline at end of file diff --git a/app/views/owners/_owners_table.html.erb b/app/views/owners/_owners_table.html.erb index 34f0655656f..4ad86899233 100644 --- a/app/views/owners/_owners_table.html.erb +++ b/app/views/owners/_owners_table.html.erb @@ -13,6 +13,9 @@ <%= t("owners.index.added_by") %> + + <%= t("owners.index.role") %> + <%= t("owners.index.confirmed_at") %> @@ -34,21 +37,30 @@ <%= mfa_status(ownership.user) %> - <%= t("profiles.edit.mfa.level." + ownership.user.mfa_level) %> + <%= t("settings.edit.mfa.level." + ownership.user.mfa_level) %> <%= ownership.authorizer_name %> + + <%= Ownership.human_attribute_name("role.#{ownership.role}") %> + <%= ownership.confirmed_at.strftime("%Y-%m-%d %H:%M %Z") if ownership.confirmed? %> <%= button_to "Remove", - rubygem_owner_path(@rubygem, ownership.user.display_id), + rubygem_owner_path(@rubygem.slug, ownership.user.display_id), method: "delete", data: { confirm: t("owners.index.confirm_remove") }, class: "form__submit form__submit--small" %> +
          + <%= button_to "Edit", + edit_rubygem_owner_path(@rubygem.name, ownership.user.display_id), + disabled: ownership.user == current_user, + method: "get", + class: "form__submit form__submit--small" %> <% end %> diff --git a/app/views/owners/edit.html.erb b/app/views/owners/edit.html.erb new file mode 100644 index 00000000000..e1e7bfcd9d4 --- /dev/null +++ b/app/views/owners/edit.html.erb @@ -0,0 +1,21 @@ +<% @title = t('.title') %> +<% @roles = Ownership.roles.map { |k,_| [Ownership.human_attribute_name("role.#{k}"), k] } %> + +<%= form_tag rubygem_owner_path(rubygem_id: @rubygem.slug, owner_id: @ownership.user.display_id), method: :patch do |form| %> + <%= error_messages_for(@ownership) %> + +
          + <%= label_tag :display_id, "User", class: 'form__label' %> + <%= text_field_tag :display_id, @ownership.user.display_id, disabled: true, :class => 'form__input' %> +
          + +
          + <%= label_tag :role, t(".role"), class: 'form__label' %> +
          + <%= select_tag :role, options_for_select(@roles, @ownership.role), class: "form__input form__select" %> +
          + +
          + <%= submit_tag 'Update', :data => {:disable_with => t('form_disable_with')}, :class => 'form__submit' %> +
          +<% end %> diff --git a/app/views/owners/index.html.erb b/app/views/owners/index.html.erb index 75bc1084b47..957f5a24daa 100644 --- a/app/views/owners/index.html.erb +++ b/app/views/owners/index.html.erb @@ -1,5 +1,6 @@ <% @title = @rubygem.name %> <% @subtitle = t(".info", gem: @rubygem.name) %> +<% @roles = Ownership.roles.map { |k,_| [Ownership.human_attribute_name("role.#{k}"), k] } %>
          <%= render "owners_table" %> @@ -14,6 +15,11 @@ <%= label_tag :handle, t(".email_field"), class: "form__label" %> <%= text_field_tag :handle, nil, class: "form__input", required: true %>
          +
          + <%= label_tag :role, t(".role_field"), class: "form__label" %> +
          + <%= select_tag :role, options_for_select(@roles), class: "form__input form__select" %> +
          <%= submit_tag t(".submit_button"), data: {disable_with: t("form_disable_with")}, class: "form__submit" %>
          diff --git a/app/views/owners_mailer/new_ownership_requests.html.erb b/app/views/owners_mailer/new_ownership_requests.html.erb new file mode 100644 index 00000000000..4c68882a773 --- /dev/null +++ b/app/views/owners_mailer/new_ownership_requests.html.erb @@ -0,0 +1,66 @@ +<% @title = t("mailer.ownerhip_request_closed.title") %> +<% @sub_title = @rubygem.name %> + + + + + + + + +
          +
           
          + +
           
          + +
          + <%= t("mailer.new_ownership_requests.body_html", count: @ownership_requests_count.to_s, gem: @rubygem.name) %>
          +
          + +

          + +

          + + +
           
          + + + + + + +
          + + + + +
          + + + + + + +
           
          +
          + +
          +
          +
          + + +
          +
          + + <%= t("mailer.new_ownership_requests.disable_notifications") %> <%= link_to("email notification settings", notifier_url) %> + +
          + +
           
          + +
           
          + +
          + diff --git a/app/views/owners_mailer/owner_added.html.erb b/app/views/owners_mailer/owner_added.html.erb index eee09fcb49b..fec030b05c6 100644 --- a/app/views/owners_mailer/owner_added.html.erb +++ b/app/views/owners_mailer/owner_added.html.erb @@ -9,7 +9,7 @@

          - <%= t("mailer.owner_added.body_#{owner_i18n_key(@owner, @user)}_html", gem: @rubygem.name, owner_handle: @owner.display_handle, authorizer: @authorizer.display_handle) %> + <%= t("mailer.owner_added.body_#{owner_i18n_key(@owner, @user)}_html", gem: @rubygem.name, owner_handle: @owner.display_handle, authorizer: @authorizer.display_handle, host: Gemcutter::HOST_DISPLAY) %>

           
          @@ -20,7 +20,7 @@

          <%= render "mailer/compromised_instructions" do %> -
        • Remove <%= @owner.display_handle %> as an owner from <%= @rubygem.name %> gem using gem owner -r <%= @owner.display_handle %> or <%= link_to "ownership page", rubygem_owners_url(@rubygem) %>
        • +
        • Remove <%= @owner.display_handle %> as an owner from <%= @rubygem.name %> gem using gem owner -r <%= @owner.display_handle %> or <%= link_to "ownership page", rubygem_owners_url(@rubygem.slug) %>
        • Look out for unexpected changes to your gems on RubyGems.org
        • <% end %> diff --git a/app/views/owners_mailer/owner_removed.html.erb b/app/views/owners_mailer/owner_removed.html.erb index 6ef5ed2d46d..cf1a48dcde8 100644 --- a/app/views/owners_mailer/owner_removed.html.erb +++ b/app/views/owners_mailer/owner_removed.html.erb @@ -9,7 +9,7 @@

          - <%= t("mailer.owner_removed.body_html", gem: @rubygem.name, remover: @remover.display_handle) %> + <%= t("mailer.owner_removed.body_html", gem: @rubygem.name, remover: @remover.display_handle, host: Gemcutter::HOST_DISPLAY) %>

           
          diff --git a/app/views/owners_mailer/owner_updated.html.erb b/app/views/owners_mailer/owner_updated.html.erb new file mode 100644 index 00000000000..9c7a8a1e286 --- /dev/null +++ b/app/views/owners_mailer/owner_updated.html.erb @@ -0,0 +1,40 @@ +<% @title = t("mailer.owner_updated.title") %> +<% @sub_title = t("mailer.owner_updated.subtitle", user_handle: @user.handle) %> + + + + + + + + +
          +
          +
          +

          + <%= t("mailer.owner_updated.body_html", gem: @rubygem.name, host: Gemcutter::HOST_DISPLAY, role: Ownership.human_attribute_name("role.#{@ownership.role}")) %> +

          +
           
          + +

          If this change is expected, you do not need to take further action.

          +

          + Only if this change is unexpected + please take immediate steps to secure your account and gems: +

          + + <%= render "mailer/compromised_instructions" do %> +
        • Look out for unexpected changes to your gems on RubyGems.org
        • + <% end %> + +

          + + To stop receiving these messages, update your <%= link_to("email notification settings", notifier_url) %>. + +

          +
          + +
           
          + +
           
          + +
          diff --git a/app/views/owners_mailer/owner_updated.text.erb b/app/views/owners_mailer/owner_updated.text.erb new file mode 100644 index 00000000000..b68561ccfd8 --- /dev/null +++ b/app/views/owners_mailer/owner_updated.text.erb @@ -0,0 +1,5 @@ +<%= t("mailer.owner_updated.subtitle", user_handle: @user.handle) %> + +<%= t("mailer.owner_updated.body_text", gem: @rubygem.name, host: Gemcutter::HOST_DISPLAY, role: Ownership.human_attribute_name("role.#{@ownership.role}")) %> + +<%= rubygem_owners_url(@rubygem.slug) %> diff --git a/app/views/owners_mailer/ownership_confirmation.html.erb b/app/views/owners_mailer/ownership_confirmation.html.erb index 30839e6ea55..1d76dddd526 100644 --- a/app/views/owners_mailer/ownership_confirmation.html.erb +++ b/app/views/owners_mailer/ownership_confirmation.html.erb @@ -29,7 +29,7 @@ diff --git a/app/views/owners_mailer/ownership_confirmation.text.erb b/app/views/owners_mailer/ownership_confirmation.text.erb new file mode 100644 index 00000000000..89d7dfeb8ac --- /dev/null +++ b/app/views/owners_mailer/ownership_confirmation.text.erb @@ -0,0 +1,4 @@ +<%= t("mailer.ownership_confirmation.subtitle", handle: @user.handle) %> + +<%= t("mailer.ownership_confirmation.body_text", gem: @rubygem.name, authorizer: @ownership.authorizer.display_handle) %> +<%= confirm_rubygem_owners_url(@rubygem.slug, token: @ownership.token.html_safe) %> diff --git a/app/views/owners_mailer/ownership_request_approved.html.erb b/app/views/owners_mailer/ownership_request_approved.html.erb new file mode 100644 index 00000000000..86a916a7ac7 --- /dev/null +++ b/app/views/owners_mailer/ownership_request_approved.html.erb @@ -0,0 +1,53 @@ +<% @title = t("mailer.ownerhip_request_closed.title") %> +<% @sub_title = t("mailer.ownerhip_request_closed.subtitle", handle: @user.display_handle) %> + + + + + + + + +
          +
          +
          +

          + <%= t("mailer.ownerhip_request_approved.body_html", gem: @rubygem.name) %> +

          + +
          + + + + + +
          + + + + +
          + + + + + + +
           
          +
          + +
          +
          +
          + + +
           
          +
          + +
           
          + +
           
          + +
          diff --git a/app/views/owners_mailer/ownership_request_closed.html.erb b/app/views/owners_mailer/ownership_request_closed.html.erb new file mode 100644 index 00000000000..be508ad5b81 --- /dev/null +++ b/app/views/owners_mailer/ownership_request_closed.html.erb @@ -0,0 +1,19 @@ +<% @title = t("mailer.ownerhip_request_closed.title") %> +<% @sub_title = t("mailer.ownerhip_request_closed.subtitle", handle: @user.display_handle) %> + + + + + + + + +
          +
          +
          +

          + <%= t("mailer.ownerhip_request_closed.body_html", gem: @rubygem.name) %> +

          +
           
          +
          +
          diff --git a/app/views/ownership_calls/_apply.html.erb b/app/views/ownership_calls/_apply.html.erb new file mode 100644 index 00000000000..b391b33e089 --- /dev/null +++ b/app/views/ownership_calls/_apply.html.erb @@ -0,0 +1 @@ +<%= link_to t("ownership_calls.apply"), rubygem_adoptions_path(ownership_call.rubygem.slug), class: "form__submit form__submit--medium" %> diff --git a/app/views/ownership_calls/_close.html.erb b/app/views/ownership_calls/_close.html.erb new file mode 100644 index 00000000000..1d9b5e119a4 --- /dev/null +++ b/app/views/ownership_calls/_close.html.erb @@ -0,0 +1,3 @@ +<% if policy(ownership_call).close? %> +
          <%= button_to t("ownership_calls.close"), close_rubygem_ownership_calls_path(ownership_call.rubygem.slug), method: :patch, class: "form__submit form__submit--medium" %>
          +<% end %> diff --git a/app/views/ownership_calls/_form.html.erb b/app/views/ownership_calls/_form.html.erb new file mode 100644 index 00000000000..adea430b65d --- /dev/null +++ b/app/views/ownership_calls/_form.html.erb @@ -0,0 +1,12 @@ +
          + <%= form_tag rubygem_ownership_calls_path, method: :post, class: "ownership_call_form", id: "new_ownership_call" do %> +
          + <%= label_tag :note, t("ownership_calls.note_for_applicants"), class: "form__label" %> + <%= t("ownership_calls.markup_supported_html") %> + <%= text_area_tag :note, nil, placeholder: t("ownership_calls.share_requirements"), class: "form__input form__textarea", required: true %> +
          +
          + <%= submit_tag t("ownership_calls.create_call"), data: { disable_with: t("form_disable_with") }, class: "form__submit" %> +
          + <% end %> +
          diff --git a/app/views/ownership_calls/_ownership_call.html.erb b/app/views/ownership_calls/_ownership_call.html.erb new file mode 100644 index 00000000000..be666eb2b50 --- /dev/null +++ b/app/views/ownership_calls/_ownership_call.html.erb @@ -0,0 +1,28 @@ +
        • +
          +
          + + <%= ownership_call.rubygem_name %> + +

          + <%= short_info(ownership_call.rubygem) %> +

          +
          +

          + <%= download_count ownership_call.rubygem %> + + <%= t("rubygems.index.downloads") %> + +

          + +
          +

          <%= t("ownership_calls.details") %>:

          + <%= sanitize_note(ownership_call.note) %> +

          + <%= t("ownership_calls.created_by") %>: + <%= link_to ownership_call.user_display_handle, profile_path(ownership_call.user), class: "t-text t-link" %> +

          + <%= yield %> +
          +
          +
        • diff --git a/app/views/ownership_calls/index.html.erb b/app/views/ownership_calls/index.html.erb new file mode 100644 index 00000000000..e659c844940 --- /dev/null +++ b/app/views/ownership_calls/index.html.erb @@ -0,0 +1,18 @@ +<% content_for :title do %> +

          + <%= t('.title') %> + <%= t(".subtitle_html") %> +

          +<% end %> + +
          +

          + <%= page_entries_info @ownership_calls, entry_name: 'ownership calls' %> +

          +
          + +
          + <%= render partial: "apply", layout: "ownership_call", collection: @ownership_calls, as: :ownership_call %> +
          + +<%= paginate @ownership_calls %> diff --git a/app/views/ownership_requests/_form.html.erb b/app/views/ownership_requests/_form.html.erb new file mode 100644 index 00000000000..7a8a3a4cf91 --- /dev/null +++ b/app/views/ownership_requests/_form.html.erb @@ -0,0 +1,12 @@ +
          + <%= form_tag rubygem_ownership_requests_path(@rubygem.slug), method: :post, class: "ownership_request_form", id: "new_ownership_request" do %> +
          + <%= label_tag :note, t("ownership_requests.note_for_owners"), class: "form__label" %> + <%= t("ownership_calls.markup_supported_html") %> + <%= text_area_tag :note, nil, placeholder: "Please share why are you requesting ownership of #{@rubygem.name}", class: "form__input form__textarea", required: true %> +
          +
          + <%= submit_tag t("ownership_requests.create_req"), data: {disable_with: t("form_disable_with")}, class: "form__submit" %> +
          + <% end %> +
          diff --git a/app/views/ownership_requests/_list.html.erb b/app/views/ownership_requests/_list.html.erb new file mode 100644 index 00000000000..4481cf4ec4a --- /dev/null +++ b/app/views/ownership_requests/_list.html.erb @@ -0,0 +1,24 @@ +
          +

          <%= t("ownership_requests.ownership_requests") %> + [?] +

          +
          + +<% if policy(@rubygem).manage_adoption? %> +
          + <%= render(partial: "ownership_requests/owner", layout: "ownership_requests/ownership_request", collection: @ownership_requests, as: :ownership_request, locals: { show_user: true, show_gem: false }) || t("ownership_requests.no_ownership_requests", gem: @rubygem.name) %> +
          + + <%= button_to t("ownership_requests.close_all"), close_all_rubygem_ownership_requests_path(@rubygem.slug), method: :patch, class: "form__submit" unless @ownership_requests.empty?%> +<% elsif @user_request %> + <%= render "ownership_requests/ownership_request", ownership_request: @user_request, show_user: false, show_gem: false do %> + <%= button_to t("ownership_calls.close"), rubygem_ownership_request_path(@user_request.rubygem.slug, @user_request), params: { status: "close" }, method: :patch, class: "form__submit form__submit--medium" %> + <% end %> + +<% elsif current_user %> + <%= render "ownership_requests/form" %> +<% else %> +
          + <%= t("ownership_requests.signin_to_create_html") %> +
          +<% end %> diff --git a/app/views/ownership_requests/_owner.html.erb b/app/views/ownership_requests/_owner.html.erb new file mode 100644 index 00000000000..8e80dbaa775 --- /dev/null +++ b/app/views/ownership_requests/_owner.html.erb @@ -0,0 +1,2 @@ +<%= button_to t("ownership_requests.approve"), rubygem_ownership_request_path(@rubygem.slug, ownership_request), params: { status: "approve" }, method: :patch, class: "form__submit form__submit--medium", form_class: "form--inline", id: "owner_approve_request" %> +<%= button_to t("ownership_calls.close"), rubygem_ownership_request_path(@rubygem.slug, ownership_request), params: { status: "close" }, method: :patch, class: "form__submit form__submit--medium", form_class: "form--inline", id: "owner_close_request" %> diff --git a/app/views/ownership_requests/_ownership_request.html.erb b/app/views/ownership_requests/_ownership_request.html.erb new file mode 100644 index 00000000000..71ccc82b2fa --- /dev/null +++ b/app/views/ownership_requests/_ownership_request.html.erb @@ -0,0 +1,39 @@ +
        • +
          + <% if show_user %> +
          + <%= link_to ownership_request.user_name, profile_path(ownership_request.user.display_id), class: "gems__gem__name" %> +
          +

          + <%= ownership_request.user.total_rubygems_count.to_s %> + <%= t("ownership_requests.gems_published") %> +

          + <% elsif show_gem %> +
          + + <%= ownership_request.rubygem_name %> + +

          + <%= short_info(ownership_request.rubygem) %> +

          +
          +

          + <%= download_count ownership_request.rubygem %> + + <%= t("rubygems.index.downloads") %> + +

          + <% end %> + +
          +

          + <%= sanitize_note(ownership_request.note) %> +

          +

          + <%= t("ownership_requests.created_at") %>: + <%= time_ago_in_words ownership_request.created_at %> ago +

          + <%= yield %> +
          +
          +
        • diff --git a/app/views/ownership_requests/_requester.html.erb b/app/views/ownership_requests/_requester.html.erb new file mode 100644 index 00000000000..e87b58f04c4 --- /dev/null +++ b/app/views/ownership_requests/_requester.html.erb @@ -0,0 +1 @@ +<%= button_to t("ownership_calls.close"), rubygem_ownership_request_path(ownership_request.rubygem, ownership_request), params: { status: "close" }, method: :patch, class: "form__submit form__submit--medium", id: "requester_close_request" %> diff --git a/app/views/pages/about.html.erb b/app/views/pages/about.html.erb index 6ade4bd8125..115142f8cad 100644 --- a/app/views/pages/about.html.erb +++ b/app/views/pages/about.html.erb @@ -9,8 +9,8 @@

      <%= t('.founding_html', :founder => link_to('Nick Quaranto', 'https://twitter.com/qrush'), - :contributors => link_to("350 Rubyists", "https://github.com/rubygems/rubygems.org/graphs/contributors"), - :downloads => link_to("millions of gem downloads", stats_path), + :contributors => link_to(t('.contributors_amount', count: 350), "https://github.com/rubygems/rubygems.org/graphs/contributors"), + :downloads => link_to(t('.downloads_amount'), stats_path), :title => t('title')) %>

      @@ -23,21 +23,21 @@ <%= t('.technical_html', :rails => link_to("Rails", "https://rubyonrails.org"), :s3 => link_to("Amazon S3", "https://aws.amazon.com/s3/"), :fastly => link_to("Fastly", "https://www.fastly.com"), - :source_code => link_to("please check out the code", "https://github.com/rubygems/rubygems.org"), - :license => link_to("MIT licensed", "https://www.opensource.org/licenses/mit-license.php") + :source_code => link_to(t('.checkout_code'), "https://github.com/rubygems/rubygems.org"), + :license => link_to(t('.mit_licensed'), "https://www.opensource.org/licenses/mit-license.php") ) %>

      -

      Looking for our logo?

      -

      Just select the download button and you’ll get three .PNGs and an .SVG of the RubyGems logo all for yourself.

      +

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

      +

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

      diff --git a/app/views/pages/data.html.erb b/app/views/pages/data.html.erb index 91b89e64407..87e7d71e94e 100644 --- a/app/views/pages/data.html.erb +++ b/app/views/pages/data.html.erb @@ -1,10 +1,12 @@ <% @title = t('.title') %> -
      +

      We provide weekly dumps of the RubyGems.org PostgreSQL data. This dump is sanitized, removing all user information.

      The load-pg-dump script is - available as an example of how to to download and load the most recent weekly dump.

      -
        -

        We also provide weekly dumps of the historial RubyGems.org Redis data. (We do not use redis anymore but these are here for historical purposes.)

        -
          + available as an example of how to to download and load the most recent weekly dump.

          +
            + +
          diff --git a/app/views/pages/download.html.erb b/app/views/pages/download.html.erb index 7b225b6248f..0cecaa6c9db 100644 --- a/app/views/pages/download.html.erb +++ b/app/views/pages/download.html.erb @@ -14,14 +14,14 @@

          Or, to upgrade to the latest RubyGems:

          -
          $ gem update --system          # may need to be administrator or root
          +
          $ gem update --system

          - If you don't have any RubyGems installed, there is still the pre-gem approach to getting software, doing it manually: + You might be running into some bug that prevents you from upgrading rubygems the standard way. In that case, you can try upgrading manually:

          1. <%=link_to "Download from above", "#formats" %>
          2. Unpack into a directory and cd there
          3. -
          4. Install with: ruby setup.rb (you may need admin/root privilege)
          5. +
          6. Install with: ruby setup.rb

          For more details and other options, see: diff --git a/app/views/pages/sponsors.html.erb b/app/views/pages/sponsors.html.erb index 15832ca30d6..7d93b9c5096 100644 --- a/app/views/pages/sponsors.html.erb +++ b/app/views/pages/sponsors.html.erb @@ -6,23 +6,15 @@

          Ruby Central

          -

          Ruby Central, Inc. is a nonprofit 501(c)3 organization dedicated to the support and advocacy of the worldwide Ruby community. They organize the annual RubyConf and RailsConf as opportunities for Rubyists to collaborate and network. As part of those conferences, Ruby Central also operates the Opportunity Scholarship program to consistently bring new people into the tech community and to provide them with a comfortable, welcoming environment to explore our community and its events.

          They also fund and operate the Ruby Central Community Grant, underwriting events or open source projects that benefit the Ruby community, and they fund the ongoing server costs associated with running RubyGems.org. -

          - -

          Ruby Together

          -

          - Ruby Together is a grassroots initiative committed to supporting the critical Ruby infrastructure you rely on: Bundler, RubyGems, and other shared tools. No more waiting around until someone has free time to apply security patches. Instead, Ruby Together pays developers for dedicated time maintaining and improving shared Ruby tools. Help us keep RubyGems.org running smoothly and free for everyone to use by joining as a developer today. -

          - Companies count on robust tools, but the volunteers who maintain critical Ruby infrastructure can't keep up in their free time. All too often, individual companies end up paying full-time engineers to privately work around issues shared by the entire Ruby community. You shouldn't pay full price for private solutions to shared issues. Spend more time building great apps, and less time on the tools that make those apps possible: <%= link_to "join Ruby Together", "https://rubytogether.org/companies?source=rubygems" %>. + You can support Ruby Central by attending or [sponsoring](sponsors@rubycentral.org) a conference, or by [joining as a supporting member](https://rubycentral.org/#/portal/signup).

          Fastly

          -

          Fastly is the future of content delivery, giving businesses complete control over how they serve content, unprecedented access to real-time performance analytics, and the ability to cache frequently changing content at the edge.

          @@ -32,6 +24,13 @@


          1Password

          -

          A password manager, digital vault, form filler and secure digital wallet. 1Password remembers all your passwords for you to help keep account information safe.

          + +
          + +

          Avo

          +

          + Avo is a custom Content Management System for Ruby on Rails that saves developers and teams months of development time. It's built on modern technologies and provides all the necessary hooks to ensure developers ship the best experiences to their customers.

          + Avo enables the RubyGems.org team to quickly build internal tools with limited resources. +

          diff --git a/app/views/clearance_mailer/change_password.html.erb b/app/views/password_mailer/change_password.html.erb similarity index 92% rename from app/views/clearance_mailer/change_password.html.erb rename to app/views/password_mailer/change_password.html.erb index 14ceed5fd6a..bd5f41cfe7d 100644 --- a/app/views/clearance_mailer/change_password.html.erb +++ b/app/views/password_mailer/change_password.html.erb @@ -1,5 +1,5 @@ <% @title = t(".title") %> -<% @sub_title = t(".subtitle", handle: @user.handle) %> +<% @sub_title = t(".subtitle", handle: @user.name) %> @@ -29,7 +29,7 @@ diff --git a/app/views/password_mailer/change_password.text.erb b/app/views/password_mailer/change_password.text.erb new file mode 100644 index 00000000000..842a5637111 --- /dev/null +++ b/app/views/password_mailer/change_password.text.erb @@ -0,0 +1,4 @@ +<%= t(".subtitle", handle: @user.name) %> + +<%= t(".opening") %> +<%= edit_password_url(token: @user.confirmation_token.html_safe) %> diff --git a/app/views/passwords/create.html.erb b/app/views/passwords/create.html.erb new file mode 100644 index 00000000000..515b9b6b154 --- /dev/null +++ b/app/views/passwords/create.html.erb @@ -0,0 +1,3 @@ +
          +

          <%= t(".success") %>

          +
          diff --git a/app/views/passwords/edit.html.erb b/app/views/passwords/edit.html.erb index 97b80b7daba..e5e5bd17d08 100644 --- a/app/views/passwords/edit.html.erb +++ b/app/views/passwords/edit.html.erb @@ -1,16 +1,18 @@ <% @title = t('.title') %> -<%= form_for(:password_reset, - :url => user_password_path(@user, :token => @user.confirmation_token), - :html => { :method => :put }) do |form| %> +<%= form_for(:password_reset, url: password_path, html: { method: :put }) do |form| %> <%= error_messages_for @user %>
          <%= form.label :password, "Password", :class => 'form__label' %> - <%= form.password_field :password, :class => 'form__input' %> + <%= form.password_field :password, autocomplete: 'new-password', class: 'form__input' %>
          <%= form.check_box :reset_api_key, { :class => 'form__checkbox__input' } , 'true', 'false' %> - <%= form.label :reset_api_key, t('profiles.edit.api_access.reset'), :class => 'form__checkbox__label' %> + <%= form.label :reset_api_key, t('settings.edit.api_access.reset'), :class => 'form__checkbox__label' %> +
          +
          + <%= form.check_box :reset_api_keys, { :class => 'form__checkbox__input' } , 'true', 'false' %> + <%= form.label :reset_api_keys, t('settings.edit.api_access.reset_all'), :class => 'form__checkbox__label' %>
          diff --git a/app/views/passwords/new.html.erb b/app/views/passwords/new.html.erb index a3156cc57d4..8cd5fea220e 100644 --- a/app/views/passwords/new.html.erb +++ b/app/views/passwords/new.html.erb @@ -4,7 +4,7 @@

          <%= t '.will_email_notice' %>

          -<%= form_for :password, :url => passwords_path do |form| %> +<%= form_for :password, url: password_path do |form| %>
          <%= form.label :email, t('activerecord.attributes.user.email'), :class => 'form__label' %> <%= form.email_field :email, :size => '25', :class => 'form__input' %> diff --git a/app/views/passwords/otp_prompt.html.erb b/app/views/passwords/otp_prompt.html.erb deleted file mode 100644 index ba0d55adf13..00000000000 --- a/app/views/passwords/otp_prompt.html.erb +++ /dev/null @@ -1,11 +0,0 @@ -<% @title = t('multifactor_authentication') %> - -<%= form_tag mfa_edit_user_password_path(@user, token: @user.confirmation_token), method: :post do %> -
          - <%= label_tag :otp, t('multifactor_auths.otp_code'), class: 'form__label' %> - <%= text_field_tag :otp, '', class: 'form__input', autofocus: true, autocomplete: :off %> -
          -
          - <%= submit_tag t('.authenticate'), data: {disable_with: t('form_disable_with')}, class: 'form__submit' %> -
          -<% end %> diff --git a/app/views/profiles/_rubygem.html.erb b/app/views/profiles/_rubygem.html.erb index 52d8b4e693c..3fa8e25824f 100644 --- a/app/views/profiles/_rubygem.html.erb +++ b/app/views/profiles/_rubygem.html.erb @@ -1,19 +1,15 @@ -
        • "> +
        • - + <%= rubygem.name %> - <%= rubygem.versions.most_recent %> + <%= rubygem.latest_version %>

          <%= number_with_delimiter(rubygem.downloads) %> Downloads

          - <% if graph %> -
          -
          - <% end %> <% if local_assigns[:owners] %>

          <%= t '.owners_header'%>:

          diff --git a/app/views/profiles/adoptions.html.erb b/app/views/profiles/adoptions.html.erb new file mode 100644 index 00000000000..f00c1664690 --- /dev/null +++ b/app/views/profiles/adoptions.html.erb @@ -0,0 +1,26 @@ +<% @title = t(".title") %> +<% @subtitle = t(".subtitle_html") %> + +

          <%= t("adoptions.index.ownership_calls") %>:

          +<% if @ownership_calls.empty? %> +
          + <%= t(".no_ownership_calls") %> +
          +<% else %> + <%= render partial: "ownership_calls/close", layout: "ownership_calls/ownership_call", collection: @ownership_calls, as: :ownership_call %> +<% end %> + +
          +

          <%= t("ownership_requests.ownership_requests") %>:

          + +<% if @ownership_requests.empty? %> +
          + <%= t(".no_ownership_requests") %> +
          +<% else %> + <% @ownership_requests.each do |ownership_request| %> + <%= render "ownership_requests/ownership_request", ownership_request: ownership_request, show_user: false, show_gem: true do %> + <%= button_to "Close", rubygem_ownership_request_path(ownership_request.rubygem, ownership_request), params: { status: "close" }, method: :patch, class: "form__submit form__submit--medium" %> + <% end %> + <% end %> +<% end %> diff --git a/app/views/profiles/delete.html.erb b/app/views/profiles/delete.html.erb index de19d1a8738..9d42b613f88 100644 --- a/app/views/profiles/delete.html.erb +++ b/app/views/profiles/delete.html.erb @@ -7,7 +7,7 @@
            - <%= render partial: 'rubygem', collection: @only_owner_gems, locals: {graph: false, owners: true} %> + <%= render partial: 'rubygem', collection: @only_owner_gems, locals: {owners: true} %>
          @@ -20,7 +20,7 @@
            - <%= render partial: 'rubygem', collection: @multi_owner_gems, locals: {graph: false, owners: true} %> + <%= render partial: 'rubygem', collection: @multi_owner_gems, locals: {owners: true} %>
          @@ -31,7 +31,7 @@ <%= form_for current_user, url: destroy_profile_path, method: :delete do |form| %>
          <%= form.label :password, "Password", class: 'form__label' %> - <%= form.password_field :password, placeholder: 'password', class: 'form__input' %> + <%= form.password_field :password, placeholder: 'password', autocomplete: 'current-password', class: 'form__input' %>
          <%= form.submit t('.confirm'), data: { confirm: "This action can't be UNDONE! Are you sure?" }, class: 'form__submit' %> <% end %> diff --git a/app/views/profiles/edit.html.erb b/app/views/profiles/edit.html.erb index 2fe3e74716c..d56e7bdf80b 100644 --- a/app/views/profiles/edit.html.erb +++ b/app/views/profiles/edit.html.erb @@ -6,8 +6,12 @@
          <%= form.label :avatar, :class => 'form__label' %>
          - <%= gravatar(160) %> - <%= link_to t('.change_avatar'), 'https://www.gravatar.com', :class => 't-text t-link' %> + <%= avatar(160) %> + <% if @user.public_email? %> + <%= link_to t('.change_avatar'), 'https://www.gravatar.com', :class => 't-text t-link' %> + <% else %> + <%= content_tag 'i', t('.disabled_avatar_html'), :class => 't-text' %> + <% end %>
          @@ -19,10 +23,10 @@
          <%= form.label :twitter_username, class: 'form__label form__label__icon-container' do %> <%= - image_tag("/images/twitter_icon.png", alt: 'Twitter icon', class: 'form__label__icon') + image_tag("/images/x_icon.png", alt: 'X icon', class: 'form__label__icon') %> - Twitter username + <%= t('.twitter_username') %> <% end %>

          @@ -49,18 +53,26 @@ <%= form.email_field :email, name: 'user[unconfirmed_email]', class: 'form__input' %>

          -

          - <%= t('.enter_password') %> -

          +
          + <%= form.label :full_name, :class => 'form__label' %> +

          + <%= t('.optional_full_name') %> +

          + <%= form.text_field :full_name, :class => 'form__input' %> +
          +
          <%= form.label :password, :class => 'form__label' %> - <%= form.password_field :password, :class => 'form__input' %> +

          + <%= t('.enter_password') %> +

          + <%= form.password_field :password, autocomplete: 'current-password', class: 'form__input' %>
          - <%= form.check_box :hide_email, :class => 'form__checkbox__input' %> - <%= form.label :hide_email, t('.hide_email'), :class => 'form__checkbox__label' %> + <%= form.check_box :public_email, :class => 'form__checkbox__input' %> + <%= form.label :public_email, t('profiles.public_email'), :class => 'form__checkbox__label' %>
          diff --git a/app/views/profiles/security_events_view.rb b/app/views/profiles/security_events_view.rb new file mode 100644 index 00000000000..31e4f133609 --- /dev/null +++ b/app/views/profiles/security_events_view.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class Profiles::SecurityEventsView < ApplicationView + include Phlex::Rails::Helpers::ButtonTo + include Phlex::Rails::Helpers::ContentFor + include Phlex::Rails::Helpers::DistanceOfTimeInWordsToNow + include Phlex::Rails::Helpers::TimeTag + include Phlex::Rails::Helpers::LinkTo + extend Dry::Initializer + + option :security_events + + def view_template + title_content + + div(class: "tw-space-y-2 t-body") do + p do + t(".description_html") + end + + render Events::TableComponent.new(security_events: security_events) + end + end + + def title_content + self.title_for_header_only = t(".title") + content_for :title do + h1(class: "t-display page__heading page__heading-small") do + plain t(".title") + end + end + end +end diff --git a/app/views/profiles/show.html.erb b/app/views/profiles/show.html.erb index 3faae69ba08..d53b7f5ca94 100644 --- a/app/views/profiles/show.html.erb +++ b/app/views/profiles/show.html.erb @@ -1,26 +1,30 @@ +<% @title_for_header_only = t('.title', username: @user.display_id) %> + <% content_for :title do %>
          - <%= - image_tag( - @user.gravatar_url(size: 300, secure: request.ssl?).html_safe, - id: "profile_gravatar", - width: 300, - height: 300, - class: 'profile__header__avatar' - ) - %> + <%= avatar(300, 'profile_gravatar', @user, class: 'profile__header__avatar') %>
          -

          - <%= - link_to( - @user.display_handle, profile_path(@user.display_id), - class: "t-link--black" - ) - %> -

          + <% if @user.full_name.present? %> +

          + <%= link_to(@user.full_name, profile_path(@user.display_id), class: "t-link--black") %> +

          + +

          + <%= link_to(@user.display_handle, profile_path(@user.display_id), class: "t-link--black") %> +

          + <% else %> +

          + <%= + link_to( + @user.display_handle, profile_path(@user.display_id), + class: "t-link--black" + ) + %> +

          + <% end %> <% if @user == current_user %>
          @@ -35,6 +39,18 @@ %>
          +
          + <%= + link_to( + "Adoptions", + adoptions_profile_path, + id: "ownership-requests", + class: "profile__header__attribute t-link--black", + "data-icon": "⟡" + ) + %> +
          +
          <%= link_to( @@ -47,7 +63,7 @@ %>
          <% else %> - <% unless @user.hide_email %> + <% if @user.public_email? %>

          <%= mail_to( @@ -64,8 +80,8 @@ <% if @user.twitter_username.present? %> <%= image_tag( - "/images/twitter_icon.png", - alt: "Twitter icon", + "/images/x_icon.png", + alt: "X icon", class: "profile__header__icon" ) %> @@ -104,13 +120,7 @@

            - <%= - render( - partial: 'rubygem', - collection: @rubygems + @extra_rubygems, - locals: {graph: false} - ) - %> + <%= render partial: 'rubygem', collection: @rubygems %>
          diff --git a/app/views/reverse_dependencies/_search.html.erb b/app/views/reverse_dependencies/_search.html.erb index 3cc6a4f679e..5bb39643d0b 100644 --- a/app/views/reverse_dependencies/_search.html.erb +++ b/app/views/reverse_dependencies/_search.html.erb @@ -1,8 +1,8 @@ -<%= form_tag rubygem_reverse_dependencies_path(rubygem), - id: "rdeps-search", class: "header__search-wrap", method: :get do %> - <%= search_field_tag :rdeps_query, params[:rdeps_query], placeholder: t('.search_reverse_dependencies_html'), class: "header__search" %> +<%= form_tag rubygem_reverse_dependencies_path(rubygem.slug), + id: "rdeps-search", class: "home__search-wrap", method: :get do %> + <%= search_field_tag :rdeps_query, params[:rdeps_query], placeholder: t('.search_reverse_dependencies_html'), class: "home__search" %> <%= label_tag :rdeps_query do %> <%= t('.search_reverse_dependencies_html') %> <% end %> - <%= submit_tag '⌕', id: 'rdeps_search_submit', name: nil, class: "header__search__icon" %> + <%= submit_tag '⌕', id: 'rdeps_search_submit', name: nil, class: "home__search__icon" %> <% end %> diff --git a/app/views/reverse_dependencies/index.html.erb b/app/views/reverse_dependencies/index.html.erb index ed94be65476..93a6533b12e 100644 --- a/app/views/reverse_dependencies/index.html.erb +++ b/app/views/reverse_dependencies/index.html.erb @@ -1,16 +1,26 @@ -<% @title = t('.title', name: @rubygem.name) %> -<% @subtitle_block = t(".subtitle", name: @rubygem.name) %> +<% content_for :title do %> +

          + <%= t('.title', name: @rubygem.name) %> + <%= t(".subtitle", name: @rubygem.name) %> +

          +<% end %>
          -
          -
          - <%= render "search", rubygem: @rubygem %> -
          + <% if @reverse_dependencies.present? %> +
          +
          + <%= render "search", rubygem: @rubygem %> +
          -
          - <%= render partial: "rubygems/rubygem", collection: @reverse_dependencies %> +
          + <%= render partial: "rubygems/rubygem", collection: @reverse_dependencies %> +
          + <%= plain_paginate @reverse_dependencies %>
          - <%= plain_paginate @reverse_dependencies %> -
          + <% else %> +
          +

          <%= t(".no_reverse_dependencies") %>

          +
          + <% end %> <%= render 'rubygems/aside' %>
          diff --git a/app/views/rubygems/_aside.html.erb b/app/views/rubygems/_aside.html.erb index 91d3e0b7222..9e011629aaf 100644 --- a/app/views/rubygems/_aside.html.erb +++ b/app/views/rubygems/_aside.html.erb @@ -1,9 +1,10 @@
          - <% if github_data_params = github_params(@rubygem) %> <%= render partial: "rubygems/github_button", locals: { github_data_params: github_data_params } %> <% end %> - + <% if @adoption %> + <%= link_to "adoption", rubygem_adoptions_path(@rubygem.slug), class: "adoption__tag" %> + <% end %>

          <%= t('stats.index.total_downloads') %> @@ -15,24 +16,12 @@

          - <% if @latest_version.indexed %> -

          - <%= t '.bundler_header' %>: -
          - - = - <%= t('.copy_to_clipboard') %> - <%= t('.copied') %> -
          -

          -

          - <%= t '.install' %>: -
          - - = -
          -

          - <% end %> +

          + <%= t('.gem_version_age') %>: + +

          <%= local_time_ago(@latest_version.authored_at) %>

          +
          +

          <%= pluralized_licenses_header @latest_version %>: @@ -40,7 +29,6 @@

          <%= formatted_licenses @latest_version.licenses %>

          -

          <%= t('.required_ruby_version') %>: @@ -51,20 +39,34 @@ <% end %>

          - + <% if @rubygem.metadata_mfa_required? %> +

          + <%= t('.requires_mfa') %>: + + true + +

          + <% end %> + <% if @latest_version.rubygems_metadata_mfa_required? %> +

          + <%= t('.released_with_mfa') %>: + + true + +

          + <% end %> <% if @latest_version.required_rubygems_version != '>= 0' %>

          <%= t('.required_rubygems_version') %>: - <%= @latest_version.required_rubygems_version %> + <%= @latest_version.required_rubygems_version %>

          <% end %> -

          <%= t '.links.header' %>:

          <%- @versioned_links.each do |name, link| %> - <%= link_to_page name, link %> + <%= link_to_page name, link, verified: @versioned_links.verified?(link) %> <%- end %> <%= change_diff_link(@rubygem, @latest_version) %> <%= badge_link(@rubygem) %> @@ -72,7 +74,11 @@ <%= atom_link(@rubygem) %> <%= report_abuse_link(@rubygem) %> <%= reverse_dependencies_link(@rubygem) %> - <%= ownership_link(@rubygem) if @rubygem.owned_by?(current_user) %> + <%= ownership_link(@rubygem) if policy(@rubygem).show_unconfirmed_ownerships? %> + <%= rubygem_trusted_publishers_link(@rubygem) if policy(@rubygem).configure_trusted_publishers? %> + <%= oidc_api_key_role_links(@rubygem) if policy(@rubygem).configure_oidc? %> <%= resend_owner_confirmation_link(@rubygem) if @rubygem.unconfirmed_ownership?(current_user) %> + <%= rubygem_adoptions_link(@rubygem) if policy(@rubygem).show_adoption? %> + <%= rubygem_security_events_link(@rubygem) if policy(@rubygem).show_events? %>
          diff --git a/app/views/rubygems/_dependencies.html.erb b/app/views/rubygems/_dependencies.html.erb index 4ec1ae09406..47a8840d928 100644 --- a/app/views/rubygems/_dependencies.html.erb +++ b/app/views/rubygems/_dependencies.html.erb @@ -1,23 +1,15 @@ <% if dependencies.present? && @latest_version.indexed %> -
          -
          -

          <%= t '.header', :title => dependencies.first.scope.titleize %> (<%= dependencies.size %>):

          -
          - <% dependencies.each do |dependency| %> - <% if rubygem = dependency.rubygem %> -
        • - <%= link_to rubygem_path(rubygem), :class => 't-list__item push--top-s' do %> - <%= rubygem.name %> - <% end %> - <%= dependency.clean_requirements %> -
        • - <% elsif dependency&.name %> -

          - <%= dependency.name %> <%= dependency.clean_requirements %> -

          - <% end %> +
          +

          <%= t '.header', :title => dependencies.first.scope.titleize %> (<%= dependencies.size %>):

          +
          + <% dependencies.each do |dependency| %> + <% dependency.rubygem.tap do |rubygem| %> +
          "> + <%= link_to_if rubygem, tag.strong(rubygem&.name || dependency.name), rubygem_path(rubygem&.slug || "-"), class: "t-list__item" %> + <%= dependency.clean_requirements %> +
          <% end %> -
          + <% end %>
          <% end %> diff --git a/app/views/rubygems/_gem_members.html.erb b/app/views/rubygems/_gem_members.html.erb index bb31b49a538..3b4461ac44f 100644 --- a/app/views/rubygems/_gem_members.html.erb +++ b/app/views/rubygems/_gem_members.html.erb @@ -7,7 +7,13 @@ <% if rubygem.owned_by?(current_user) %> <% if rubygem.owners.without_mfa.present? %> - <%= t '.not_using_mfa_warning_show' %> + <% if current_user.mfa_disabled? %> + + <%= t '.self_no_mfa_warning_html' %> + + <% else %> + <%= t '.not_using_mfa_warning_show' %> + <% end %>
          <%= t '.not_using_mfa_warning_hide' %> @@ -26,6 +32,11 @@
          <%= link_to_user(latest_version.pusher) %>
          + <% elsif latest_version.pusher_api_key&.owner.present? %> +

          <%= t '.pushed_by' %>:

          +
          + <%= link_to_pusher(latest_version.pusher_api_key.owner) %> +
          <% end %> <% if latest_version.yanker.present? %> @@ -46,8 +57,17 @@ <% if latest_version.sha256.present? %>

          <%= t '.sha_256_checksum' %>:

          -
          - <%= latest_version.sha256_hex %> + <%= copy_field_tag("gem_sha_256_checksum", latest_version.sha256_hex) %> + <% end %> + + <% if latest_version.cert_chain.present? %> +

          <%= t '.signature_period' %>:

          +
          + <%= nice_date_for(latest_version.cert_chain_valid_not_before) %> - + <%= nice_date_for(latest_version.cert_chain_valid_not_after) %> + <% if latest_version.signature_expired? %> + (<%= t '.expired' %>) + <% end %>
          <% end %>
          diff --git a/app/views/rubygems/_github_button.html.erb b/app/views/rubygems/_github_button.html.erb index 3d790d12b2d..affc22a71af 100644 --- a/app/views/rubygems/_github_button.html.erb +++ b/app/views/rubygems/_github_button.html.erb @@ -1,7 +1,7 @@ - %> - +<%= content_tag :span, class: 'github-btn', data: github_data_params do %> + - + - - + +<% end %> diff --git a/app/views/rubygems/_version_navigation.html.erb b/app/views/rubygems/_version_navigation.html.erb index 4c7627ad57a..ca23d490053 100644 --- a/app/views/rubygems/_version_navigation.html.erb +++ b/app/views/rubygems/_version_navigation.html.erb @@ -1,9 +1,9 @@
          <% if latest_version.previous.present? %> - <%= link_to "← Previous version", rubygem_version_path(rubygem, latest_version.previous.number), class: "gem__previous__version" %> + <%= link_to t('.previous_version'), rubygem_version_path(rubygem.slug, latest_version.previous.number), class: "gem__previous__version" %> <% end %> <% if latest_version.next.present? %> - <%= link_to "Next version →", rubygem_version_path(rubygem, latest_version.next.number), class: "gem__next__version" %> + <%= link_to t('.next_version'), rubygem_version_path(rubygem.slug, latest_version.next.number), class: "gem__next__version" %> <% end %>
          diff --git a/app/views/rubygems/blacklisted.html.erb b/app/views/rubygems/blacklisted.html.erb deleted file mode 100644 index 96a2db69b12..00000000000 --- a/app/views/rubygems/blacklisted.html.erb +++ /dev/null @@ -1,4 +0,0 @@ -<% @title = @blacklisted_gem %> -
          -

          <%= t '.blacklisted_namespace' %>

          -
          diff --git a/app/views/rubygems/index.html.erb b/app/views/rubygems/index.html.erb index ae2bbc10701..d8cf166c2f6 100644 --- a/app/views/rubygems/index.html.erb +++ b/app/views/rubygems/index.html.erb @@ -4,12 +4,6 @@ <%= link_to_directory %>
          -
          -

          - <%= page_entries_info @gems, :entry_name => 'gem'%> -

          -
          - <%= render @gems %> <%= paginate @gems %> diff --git a/app/views/rubygems/reserved.html.erb b/app/views/rubygems/reserved.html.erb new file mode 100644 index 00000000000..5a7a31cd1d4 --- /dev/null +++ b/app/views/rubygems/reserved.html.erb @@ -0,0 +1,4 @@ +<% @title = @reserved_gem %> +
          +

          <%= t '.reserved_namespace' %>

          +
          diff --git a/app/views/rubygems/security_events_view.rb b/app/views/rubygems/security_events_view.rb new file mode 100644 index 00000000000..79c2e01bd71 --- /dev/null +++ b/app/views/rubygems/security_events_view.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class Rubygems::SecurityEventsView < ApplicationView + include Phlex::Rails::Helpers::ButtonTo + include Phlex::Rails::Helpers::ContentFor + include Phlex::Rails::Helpers::LinkTo + extend Dry::Initializer + + option :rubygem + option :security_events + + def view_template + title_content + + div(class: "tw-space-y-2 t-body") do + p do + t(".description_html", gem: helpers.link_to(rubygem.name, rubygem_path(rubygem.slug))) + end + + render Events::TableComponent.new(security_events: security_events) + end + end + + def title_content + self.title_for_header_only = t(".title") + content_for :title do + h1(class: "t-display page__heading page__heading-small") do + plain t(".title") + end + end + end +end diff --git a/app/views/rubygems/show.html.erb b/app/views/rubygems/show.html.erb index 3039d575c2e..a1703a598b0 100644 --- a/app/views/rubygems/show.html.erb +++ b/app/views/rubygems/show.html.erb @@ -1,10 +1,18 @@ <% @title = @rubygem.name %> +<% @title_url = rubygem_path(@rubygem.slug) %> <% @subtitle = @latest_version&.slug %> <% content_for :head do %> - <%= auto_discovery_link_tag(:atom, rubygem_versions_path(@rubygem, format: "atom"), {title: "#{@rubygem.name} Version Feed"}) %> + <%= auto_discovery_link_tag(:atom, rubygem_versions_path(@rubygem.slug, format: "atom"), {title: "#{@rubygem.name} Version Feed"}) %> <% if @rubygem.versions.any? && @latest_version.indexed %> - + + + + <% I18n.available_locales.each do |locale| %> + + <% end %> + + <% else %> <% end %> @@ -19,6 +27,17 @@ <%= simple_markup(@latest_version.info) %>
          + +
          +

          + <%= t '.bundler_header' %>: + <%= copy_field_tag("gemfile_text", @latest_version.to_bundler(locked_version: @on_version_page)) %> +

          +

          + <%= t '.install' %>: + <%= copy_field_tag("install_text", @latest_version.to_install) %> +

          +
          <% else %>

          <%=t '.yanked_notice' %>

          @@ -33,7 +52,7 @@ <%= render @versions %> <% if show_all_versions_link?(@rubygem) %> - <%= link_to t('.show_all_versions', :count => @rubygem.versions.count), rubygem_versions_url(@rubygem), :class => "gem__see-all-versions t-link--gray t-link--has-arrow" %> + <%= link_to t('.show_all_versions', :count => @rubygem.versions.count), rubygem_versions_url(@rubygem.slug), :class => "gem__see-all-versions t-link--gray t-link--has-arrow" %> <% end %>
          @@ -44,7 +63,7 @@ <%= render :partial => "rubygems/dependencies", :locals => { :dependencies => @latest_version.dependencies.development } %> <% if @latest_version.dependencies.present? && @latest_version.indexed %>
          - <%= link_to t(:dependency_list), rubygem_version_dependencies_path(@rubygem, @latest_version.number), class: "gem__see-all-versions t-link--gray t-link--has-arrow push--s" %> + <%= link_to t(:dependency_list), rubygem_version_dependencies_path(@rubygem.slug, @latest_version.slug), class: "gem__see-all-versions t-link--gray t-link--has-arrow push--s" %>
          <% end %>
          diff --git a/app/views/searches/advanced.html.erb b/app/views/searches/advanced.html.erb index dac724d8101..0a5ccd43042 100644 --- a/app/views/searches/advanced.html.erb +++ b/app/views/searches/advanced.html.erb @@ -1,28 +1,36 @@ <% @title = t("advanced_search") %> -
          + diff --git a/app/views/searches/show.html.erb b/app/views/searches/show.html.erb index f2ebeee0ea9..2075bfb7830 100644 --- a/app/views/searches/show.html.erb +++ b/app/views/searches/show.html.erb @@ -7,10 +7,10 @@ <% end %> <%= link_to t("advanced_search"), advanced_search_path, class: "t-link--gray t-link--has-arrow" %> <% if @yanked_filter %> - <% @subtitle = t('.subtitle', :query => content_tag(:em, h(params[:query]))) %> + <% @subtitle = t('.subtitle_html', :query => params[:query]) %> <% if @yanked_gem.present? %> - <%= link_to rubygem_path(@yanked_gem.name), :class => 'gems__gem' do %> + <%= link_to rubygem_path(@yanked_gem.slug), :class => 'gems__gem' do %>

          <%= @yanked_gem.name %> @@ -22,10 +22,10 @@ <% end %> <% else %> <% if @gems %> - <% @subtitle = t('.subtitle', :query => content_tag(:em, h(params[:query]))) %> + <% @subtitle = t('.subtitle_html', :query => params[:query]) %>
          -

          <%= page_entries_info(@gems, :entry_name => 'gem').html_safe %>

          +

          <%= page_entries_info(@gems, :entry_name => 'gem') %>

          <%= render partial: 'aggregations', locals: { gems: @gems } %> @@ -35,9 +35,9 @@

          <%= t(".suggestion") %> - <%= suggestions.map do |term| + <%= to_sentence(suggestions.map do |term| link_to term, search_path(params: { query: term }), only_path: true - end.to_sentence(last_word_connector: ' or ').html_safe %>? + end, last_word_connector: ' or ') %>?

          <% end %> diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 6611059c2a4..099daa2ab84 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -14,9 +14,11 @@

          <%= form.label :password, t('activerecord.attributes.session.password'), :class => 'form__label' %> - <%= form.password_field :password, :class => 'form__input' %> + <%= form.password_field :password, autocomplete: 'current-password', class: 'form__input' %>
          <%= form.submit t('sign_in'), :data => {:disable_with => t('form_disable_with')}, :class => 'form__submit' %>
          <% end %> + +<%= render "multifactor_auths/webauthn_prompt" %> diff --git a/app/views/sessions/otp_prompt.html.erb b/app/views/sessions/otp_prompt.html.erb deleted file mode 100644 index 1319e3b10bc..00000000000 --- a/app/views/sessions/otp_prompt.html.erb +++ /dev/null @@ -1,11 +0,0 @@ -<% @title = t('multifactor_authentication') %> - -<%= form_tag mfa_create_session_path, method: :post do %> -
          - <%= label_tag :otp, t('multifactor_auths.otp_code'), class: 'form__label' %> - <%= text_field_tag :otp, '', class: 'form__input', autofocus: true, autocomplete: :off %> -
          -
          - <%= submit_tag t('sign_in'), data: {disable_with: t('form_disable_with')}, class: 'form__submit' %> -
          -<% end %> diff --git a/app/views/sessions/verify.html.erb b/app/views/sessions/verify.html.erb index e815d999a0a..490a28fecf1 100644 --- a/app/views/sessions/verify.html.erb +++ b/app/views/sessions/verify.html.erb @@ -7,9 +7,12 @@ <%= form_for :verify_password, url: authenticate_session_path, method: :post do |form| %>
          <%= form.label :password, t("activerecord.attributes.session.password"), class: "form__label" %> - <%= form.password_field :password, class: "form__input" %> + <%= form.password_field :password, autocomplete: "current-password", class: "form__input" %>
          <%= form.submit t(".confirm"), data: {disable_with: t("form_disable_with")}, class: "form__submit" %>
          <% end %> +<% if @user.webauthn_enabled?%> + <%= render "multifactor_auths/webauthn_prompt" %> +<% end %> diff --git a/app/views/settings/edit.html.erb b/app/views/settings/edit.html.erb index 3b94397ad76..81a34d6af89 100644 --- a/app/views/settings/edit.html.erb +++ b/app/views/settings/edit.html.erb @@ -1,26 +1,62 @@ <% @title = t(".title") %>
          -

          <%= t ".mfa.multifactor_auth" %>

          +

          <%= t ".mfa.multifactor_auth" %>

          <% if @user.mfa_enabled? %> -

          <%= t ".mfa.enabled" %>

          +
          +

          <%= t(".mfa.level.title")%>

          +
          +

          <%= t(".mfa.level_html") %>

          + <%= form_tag multifactor_auth_path, method: :put, id: "mfa-edit" do %> - <%= label_tag :level, t(".mfa.level.title"), class: "form__label" %> - <%= select_tag :level, options_for_select([ - [t(".mfa.level.ui_and_api"), "ui_and_api"], - [t(".mfa.level.ui_and_gem_signin"), "ui_and_gem_signin"], - [t(".mfa.level.ui_only"), "ui_only"], - [t(".mfa.level.disabled"), "disabled"]], @user.mfa_level), class: "form__input form__select" %> + <%= select_tag :level, options_for_select(@mfa_options, @user.mfa_level), class: "form__input form__select" %> + + <%= submit_tag t(".mfa.update"), class: "form__submit" %> + <% end %> + <% end %> + +
          +

          <%= t(".webauthn_credentials") %>

          + <% if @user.webauthn_enabled? %> + <%= t(".mfa.enabled") %> + <% else %> + <%= t(".mfa.disabled") %> + <% end %> +
          + +

          <%= t(".webauthn_credential_note")%>

          + + <% if @user.webauthn_credentials.none? %> +

          <%= t(".no_webauthn_credentials") %>

          + <% else %> +
          + <%= render @user.webauthn_credentials %> +
          + <% end %> + + <%= render "webauthn_credentials/form", webauthn_credential: @webauthn_credential %> + + <% if @user.totp_enabled? %> +
          +

          <%= t(".mfa.otp") %>

          + <%= t(".mfa.enabled") %> +
          +

          <%= t(".mfa.enabled_note") %>

          + <%= form_tag totp_path, method: :delete, id: "mfa-delete" do %>
          - <%= label_tag :otp, "OTP code", class: "form__label" %> + <%= label_tag :otp, t(".otp_code"), class: "form__label" %> <%= text_field_tag :otp, "", class: "form__input", autocomplete: :off %>
          - <%= submit_tag t(".mfa.update"), class: "form__submit" %> + <%= submit_tag t(".mfa.disable"), class: "form__submit" %> <% end %> <% else %> +
          +

          <%= t(".mfa.otp") %>

          + <%= t(".mfa.disabled") %> +

          - <%= t ".mfa.disabled" %> - <%= button_to t(".mfa.go_settings"), new_multifactor_auth_path, method: "get", class: "form__submit" %> + <%= t(".mfa.disabled_html") %> + <%= button_to t(".mfa.go_settings"), new_totp_path, method: "get", class: "form__submit" %>

          <% end %>
          @@ -30,9 +66,20 @@
          -

          <%= link_to t('.api_keys'), profile_api_keys_path %>

          +

          <%= link_to t('api_keys.index.api_keys'), profile_api_keys_path %>

          +
          +

          <%= link_to t('oidc.pending_trusted_publishers.index.title'), profile_oidc_pending_trusted_publishers_path %>

          + Pending trusted publishers allow you to configure trusted publishing before you have pushed the first version of a gem. For more information about how to set up trusted publishing, see the trusted publishing documentation. +
          + +<% if @user.oidc_api_key_roles.any? %> +
          +

          <%= link_to t('oidc.api_key_roles.index.api_key_roles'), profile_oidc_api_key_roles_path %>

          +
          +<% end %> + <% if @user.ownerships.any? %>

          <%= link_to t("notifiers.show.title"), notifier_path %>

          diff --git a/app/views/stats/index.html.erb b/app/views/stats/index.html.erb index f9bef912684..bcac07047d8 100644 --- a/app/views/stats/index.html.erb +++ b/app/views/stats/index.html.erb @@ -25,9 +25,9 @@ <% @most_downloaded.each do |gem| %>
          -

          <%= link_to gem.name, rubygem_path(gem) %>

          +

          <%= link_to gem.name, rubygem_path(gem.slug) %>

          -
          +
          <%= number_to_delimited gem.downloads %>
          diff --git a/app/views/multifactor_auths/new.html.erb b/app/views/totps/new.html.erb similarity index 62% rename from app/views/multifactor_auths/new.html.erb rename to app/views/totps/new.html.erb index 843531e7341..b95b37f9958 100644 --- a/app/views/multifactor_auths/new.html.erb +++ b/app/views/totps/new.html.erb @@ -2,23 +2,23 @@
          - <%= raw @qrcode_svg %> + <%= @qrcode_svg %>

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

          - <%= t('.account', account: "#{issuer}:#{current_user.email}") %>
          - <%= t('.key', key: @seed.chars.each_slice(4).map(&:join).join(' ')) %>
          - <%= t('.time_based') %> + <%= t('.account', account: "#{issuer}:#{current_user.email}") %>
          + <%= t('.key', key: @seed.chars.each_slice(4).map(&:join).join(' ')) %>
          + <%= t('.time_based') %>

          -<%= form_tag multifactor_auth_path, method: :post do %> +<%= form_tag totp_path, method: :post do %>
          <%= label_tag :otp, 'OTP code', class: 'form__label' %>

          <%= t '.otp_prompt' %>

          - <%= text_field_tag :otp, '', class: 'form__input', autocomplete: :off %> + <%= text_field_tag :otp, '', class: 'form__input', autocomplete: 'one-time-code' %>
          <%= submit_tag t('.enable'), class: 'form__submit' %> <% end %> diff --git a/app/views/users/_form.html.erb b/app/views/users/_form.html.erb index 1aab2cf75aa..55e05a1c793 100644 --- a/app/views/users/_form.html.erb +++ b/app/views/users/_form.html.erb @@ -1,5 +1,13 @@ <%= error_messages_for(@user) %> +
          + <%= form.label :full_name, :class => 'form__label' %> +

          + <%= t('profiles.edit.optional_full_name') %> +

          + <%= form.text_field :full_name, :class => 'form__input' %> +
          +
          <%= form.label :email, :class => 'form__label' %> <%= form.email_field :email, :class => 'form__input' %> @@ -9,6 +17,10 @@ <%= form.text_field :handle, :class => 'form__input' %>
          - <%= form.label :password, :class => 'form__label' %> - <%= form.password_field :password, :class => 'form__input' %> + <%= form.label :password, t('activerecord.attributes.user.password'), :class => 'form__label' %> + <%= form.password_field :password, autocomplete: "new-password", class: 'form__input' %> +
          +
          + <%= form.check_box :public_email, :class => 'form__checkbox__input' %> + <%= form.label :public_email, t('profiles.public_email'), :class => 'form__checkbox__label' %>
          diff --git a/app/views/versions/_version.html.erb b/app/views/versions/_version.html.erb index ce3ed93d695..198f54e0576 100644 --- a/app/views/versions/_version.html.erb +++ b/app/views/versions/_version.html.erb @@ -1,6 +1,6 @@
        • - <%= link_to version.number, rubygem_version_path(version.rubygem, version.slug), :class => 't-list__item' %> - - <%= nice_date_for(version.created_at) %> + <%= link_to version.number, rubygem_version_path(version.rubygem.slug, version.slug), :class => 't-list__item' %> + <%= version_date_tag(version) %> <% if version.platformed? %> <%= version.platform %> <% end %> diff --git a/app/views/versions/_versions_feed.atom.builder b/app/views/versions/_versions_feed.atom.builder index 4057bb18399..3bf38d5ce41 100644 --- a/app/views/versions/_versions_feed.atom.builder +++ b/app/views/versions/_versions_feed.atom.builder @@ -13,8 +13,8 @@ builder.feed "xmlns" => "http://www.w3.org/2005/Atom" do versions.each do |version| builder.entry do builder.title version.to_title - builder.link "rel" => "alternate", "href" => rubygem_version_url(version.rubygem, version.slug) - builder.id rubygem_version_url(version.rubygem, version.slug) + builder.link "rel" => "alternate", "href" => rubygem_version_url(version.rubygem.slug, version.slug) + builder.id rubygem_version_url(version.rubygem.slug, version.slug) builder.updated version.created_at.iso8601 builder.author { |author| author.name h(version.authors) } builder.summary version.summary if version.summary? diff --git a/app/views/versions/index.html.erb b/app/views/versions/index.html.erb index b6aa3827bae..94620b1f37d 100644 --- a/app/views/versions/index.html.erb +++ b/app/views/versions/index.html.erb @@ -4,10 +4,12 @@

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

        • <% else %> -

          <%= t('.versions_since', :count => @versions.size, :since => nice_date_for(@versions.map(&:created_at).min)) %>:

          +

          <%= t('.versions_since', :count => @versions.total_count, :since => nice_date_for(@versions.map(&:authored_at).min)) %>:

            <%= render @versions %>
          + + <%= paginate @versions %>
          <% end %> diff --git a/app/views/web_hooks_mailer/webhook_deleted.html.erb b/app/views/web_hooks_mailer/webhook_deleted.html.erb new file mode 100644 index 00000000000..d3adf7c3490 --- /dev/null +++ b/app/views/web_hooks_mailer/webhook_deleted.html.erb @@ -0,0 +1,35 @@ +<% @title = t("mailer.web_hook_deleted.title") %> +<% @sub_title = t("mailer.web_hook_deleted.subtitle", handle: @user.handle) %> + + +
          + + + + + +
          +
           
          + +
           
          + +
          + <%= t("mailer.web_hook_deleted.body_html", url: @url, failures: @failure_count) %>
          +
          + +
           
          + +
          + <% if @rubygem.present? %> + <%= t("mailer.web_hook_deleted.gem_html", gem: @rubygem.name) %> + <% else %> + <%= t("mailer.web_hook_deleted.global_html") %> + <% end %> +
          + +
           
          + +
           
          + +
          + diff --git a/app/views/web_hooks_mailer/webhook_deleted.text.erb b/app/views/web_hooks_mailer/webhook_deleted.text.erb new file mode 100644 index 00000000000..8e7c8fdf6f4 --- /dev/null +++ b/app/views/web_hooks_mailer/webhook_deleted.text.erb @@ -0,0 +1,9 @@ +<%= t("mailer.web_hook_deleted.subtitle", handle: @user.handle) %> + +<%= t("mailer.web_hook_deleted.body_text", url: @url, failures: @failure_count) %> + +<% if @rubygem.present? %> +<%= t("mailer.web_hook_deleted.gem_text", gem: @rubygem.name) %> +<% else %> +<%= t("mailer.web_hook_deleted.global_text") %> +<% end %> diff --git a/app/views/web_hooks_mailer/webhook_disabled.html.erb b/app/views/web_hooks_mailer/webhook_disabled.html.erb new file mode 100644 index 00000000000..5f105a22245 --- /dev/null +++ b/app/views/web_hooks_mailer/webhook_disabled.html.erb @@ -0,0 +1,35 @@ +<% @title = t("mailer.web_hook_disabled.title") %> +<% @sub_title = t("mailer.web_hook_disabled.subtitle", handle: @web_hook.user.handle) %> + + + + + + + + +
          +
           
          + +
           
          + +
          + <%= t("mailer.web_hook_disabled.body_html", url: @web_hook.url, last_success: @web_hook.last_success, failures_since_last_success: @web_hook.failures_since_last_success, delete_command: @delete_command, disabled_reason: @web_hook.disabled_reason) %>
          +
          + +
           
          + +
          + <% if @web_hook.rubygem.present? %> + <%= t("mailer.web_hook_disabled.gem_html", gem: @web_hook.rubygem.name) %> + <% else %> + <%= t("mailer.web_hook_disabled.global_html") %> + <% end %> +
          + +
           
          + +
           
          + +
          + diff --git a/app/views/web_hooks_mailer/webhook_disabled.text.erb b/app/views/web_hooks_mailer/webhook_disabled.text.erb new file mode 100644 index 00000000000..47e910d7ad0 --- /dev/null +++ b/app/views/web_hooks_mailer/webhook_disabled.text.erb @@ -0,0 +1,9 @@ +<%= t("mailer.web_hook_disabled.subtitle", handle: @web_hook.user.handle) %> + +<%= t("mailer.web_hook_disabled.body_text", url: @web_hook.url, last_success: @web_hook.last_success, failures_since_last_success: @web_hook.failures_since_last_success, delete_command: @delete_command, disabled_reason: @web_hook.disabled_reason) %> + +<% if @web_hook.rubygem.present? %> +<%= t("mailer.web_hook_disabled.gem_text", gem: @web_hook.rubygem.name) %> +<% else %> +<%= t("mailer.web_hook_disabled.global_text") %> +<% end %> diff --git a/app/views/webauthn_credentials/_form.html.erb b/app/views/webauthn_credentials/_form.html.erb new file mode 100644 index 00000000000..a6de5877366 --- /dev/null +++ b/app/views/webauthn_credentials/_form.html.erb @@ -0,0 +1,11 @@ +<%= form_for webauthn_credential, html: { class: "js-new-webauthn-credential--form" } do |f| %> +

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

          +
          + <%= f.label :nickname, t('.nickname'), class: 'form__label' %> + <%= f.text_field :nickname, class: 'form__input js-new-webauthn-credential--nickname', required: true, pattern: '.*\S+.*' %> +
          + + + + <%= f.submit t('.submit'), class: 'form__submit js-new-webauthn-credential--submit' %> +<% end %> diff --git a/app/views/webauthn_credentials/_webauthn_credential.html.erb b/app/views/webauthn_credentials/_webauthn_credential.html.erb new file mode 100644 index 00000000000..cb35b96ba59 --- /dev/null +++ b/app/views/webauthn_credentials/_webauthn_credential.html.erb @@ -0,0 +1,8 @@ +
          +
          <%= webauthn_credential.nickname %>
          + <%= button_to t(".delete"), + webauthn_credential, + method: :delete, + class: "form__submit form__submit--small", + data: { confirm: t(".confirm")} %> +
          diff --git a/app/views/webauthn_verifications/failed_verification.html.erb b/app/views/webauthn_verifications/failed_verification.html.erb new file mode 100644 index 00000000000..f19fa900b45 --- /dev/null +++ b/app/views/webauthn_verifications/failed_verification.html.erb @@ -0,0 +1,13 @@ +<% @title = t(".title") %> + +
          + + + + + +
          +

          <%= @message %>

          +

          <%= t(".close_browser") %>

          +
          +
          diff --git a/app/views/webauthn_verifications/prompt.html.erb b/app/views/webauthn_verifications/prompt.html.erb new file mode 100644 index 00000000000..a782ec5f5af --- /dev/null +++ b/app/views/webauthn_verifications/prompt.html.erb @@ -0,0 +1,16 @@ +<% @title = t(".title")%> + +<% if @user.webauthn_enabled? %> +
          +

          <%= t(".authenticating_as") %> <%= @user.name %>

          +

          <%= t("settings.edit.webauthn_credential_note") %>

          +
          + + <%= form_tag @webauthn_verification_url, method: :post, class: "js-webauthn-session-cli--form", data: { options: @webauthn_options.to_json } do %> +
          + + + <%= submit_tag t(".authenticate"), class: 'js-webauthn-session-cli--submit form__submit form__submit--no-hover' %> +
          + <% end %> +<% end %> diff --git a/app/views/webauthn_verifications/successful_verification.html.erb b/app/views/webauthn_verifications/successful_verification.html.erb new file mode 100644 index 00000000000..c3cca7863f2 --- /dev/null +++ b/app/views/webauthn_verifications/successful_verification.html.erb @@ -0,0 +1,9 @@ +<% @title = t(".title") %> + +
          + + + + +

          <%= t(".close_browser") %>

          +
          diff --git a/bin/brakeman b/bin/brakeman new file mode 100755 index 00000000000..ace1c9ba08a --- /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/bundle b/bin/bundle index f19acf5b5cc..ee73929e56b 100755 --- a/bin/bundle +++ b/bin/bundle @@ -1,3 +1,109 @@ #!/usr/bin/env ruby -ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) -load Gem.bin_path('bundler', 'bundle') +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'bundle' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +require "rubygems" + +m = Module.new do + module_function + + def invoked_as_script? + File.expand_path($0) == File.expand_path(__FILE__) + end + + def env_var_version + ENV["BUNDLER_VERSION"] + end + + def cli_arg_version + return unless invoked_as_script? # don't want to hijack other binstubs + return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update` + bundler_version = nil + update_index = nil + ARGV.each_with_index do |a, i| + if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN + bundler_version = a + end + next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ + bundler_version = $1 + update_index = i + end + bundler_version + end + + def gemfile + gemfile = ENV["BUNDLE_GEMFILE"] + return gemfile if gemfile && !gemfile.empty? + + File.expand_path("../Gemfile", __dir__) + end + + def lockfile + lockfile = + case File.basename(gemfile) + when "gems.rb" then gemfile.sub(/\.rb$/, gemfile) + else "#{gemfile}.lock" + end + File.expand_path(lockfile) + end + + def lockfile_version + return unless File.file?(lockfile) + lockfile_contents = File.read(lockfile) + return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ + Regexp.last_match(1) + end + + def bundler_requirement + @bundler_requirement ||= + env_var_version || + cli_arg_version || + bundler_requirement_for(lockfile_version) + end + + def bundler_requirement_for(version) + return "#{Gem::Requirement.default}.a" unless version + + bundler_gem_version = Gem::Version.new(version) + + bundler_gem_version.approximate_recommendation + end + + def load_bundler! + ENV["BUNDLE_GEMFILE"] ||= gemfile + + activate_bundler + end + + def activate_bundler + gem_error = activation_error_handling do + gem "bundler", bundler_requirement + end + return if gem_error.nil? + require_error = activation_error_handling do + require "bundler/version" + end + return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION)) + warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`" + exit 42 + end + + def activation_error_handling + yield + nil + rescue StandardError, LoadError => e + e + end +end + +m.load_bundler! + +if m.invoked_as_script? + load Gem.bin_path("bundler", "bundle") +end diff --git a/bin/fill-locales b/bin/fill-locales new file mode 100755 index 00000000000..d7c7c6c4d30 --- /dev/null +++ b/bin/fill-locales @@ -0,0 +1,29 @@ +#!/usr/bin/env ruby + +require "yaml" + +EN_PATH = "config/locales/en.yml" +OTHER_PATHS = Dir["config/locales/*.yml"] - [EN_PATH] - Dir["config/locales/avo.*.yml"] + +EN = YAML.safe_load_file(EN_PATH) + +def fill(en, other) + if en.is_a?(Hash) + en.map do |key, _| + next_other = other.is_a?(Hash) ? other&.dig(key) : nil + [key, fill(en[key], next_other)] + end.to_h + elsif en.is_a?(String) + other + else + raise "#{en.class} not handled" + end +end + +OTHER_PATHS.each do |path| + locale = path.split("/").last.split(".").first + yaml = { locale => fill(EN["en"], YAML.safe_load_file(path)[locale]) }.to_yaml + yaml.gsub!(/ +$/, "") + + File.write(path, yaml) +end diff --git a/bin/importmap b/bin/importmap new file mode 100755 index 00000000000..36502ab16c7 --- /dev/null +++ b/bin/importmap @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby + +require_relative "../config/application" +require "importmap/commands" diff --git a/bin/rails b/bin/rails index 6fb4e4051c4..efc0377492f 100755 --- a/bin/rails +++ b/bin/rails @@ -1,4 +1,4 @@ #!/usr/bin/env ruby -APP_PATH = File.expand_path('../config/application', __dir__) +APP_PATH = File.expand_path("../config/application", __dir__) require_relative "../config/boot" require "rails/commands" diff --git a/bin/rubocop b/bin/rubocop new file mode 100755 index 00000000000..40330c0ff1c --- /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 index 57923026c41..51afbda953b 100755 --- a/bin/setup +++ b/bin/setup @@ -1,11 +1,11 @@ #!/usr/bin/env ruby require "fileutils" -# path to your application root. -APP_ROOT = File.expand_path('..', __dir__) +APP_ROOT = File.expand_path("..", __dir__) +APP_NAME = "gemcutter" def system!(*args) - system(*args) || abort("\n== Command #{args} failed ==") + system(*args, exception: true) end FileUtils.chdir APP_ROOT do @@ -13,21 +13,31 @@ FileUtils.chdir APP_ROOT do # 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! 'gem install bundler --conservative' - system('bundle check') || system!('bundle install') + puts "== Installing dependencies ==" + system! "gem install bundler --conservative" + 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== 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:prepare" + + puts "\n== Seeding Database" + system! "bin/rails db:seed" + + puts "\n== Generating Assets" + system! "bin/rails assets:precompile" puts "\n== Removing old logs and tempfiles ==" - system! 'bin/rails log:clear tmp:clear' + system! "bin/rails log:clear tmp:clear" puts "\n== Restarting application server ==" - system! 'bin/rails restart' + system! "bin/rails restart" + + # puts "\n== Configuring puma-dev ==" + # system "ln -nfs #{APP_ROOT} ~/.puma-dev/#{APP_NAME}" + # system "curl -Is https://#{APP_NAME}.test/up | head -n 1" end diff --git a/bin/shoryuken-worker b/bin/shoryuken-worker index 74b63cc03e4..db38fdb135b 100755 --- a/bin/shoryuken-worker +++ b/bin/shoryuken-worker @@ -8,4 +8,4 @@ then exit 1 fi -exec bin/shoryuken --rails --require ./lib/shoryuken/sqs_worker.rb --queue "$SQS_QUEUE" +exec bin/shoryuken -R -r ./lib/shoryuken/sqs_worker.rb -q "$SQS_QUEUE" diff --git a/bin/update_vendor_cache b/bin/update_vendor_cache deleted file mode 100755 index f3d66ea231a..00000000000 --- a/bin/update_vendor_cache +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/sh -set -e - -git submodule update -f -cd vendor/cache -git checkout master -git clean -xfd -git pull origin master -cd - - -bundle install - -cd vendor/cache -git add -A -git commit -m "update gems" -git push origin HEAD -cd - - -git add vendor/cache diff --git a/config.ru b/config.ru index aa9b6a1487b..338d7967f1a 100644 --- a/config.ru +++ b/config.ru @@ -1,5 +1,7 @@ # This file is used by Rack-based servers to start the application. +require "datadog/profiling/preload" + require_relative "config/environment" run Gemcutter::Application diff --git a/config/application.rb b/config/application.rb index 2fb5beedc69..340006377d5 100644 --- a/config/application.rb +++ b/config/application.rb @@ -2,7 +2,7 @@ require "rails" # Pick the frameworks you want: -# require "active_model/railtie" +require "active_model/railtie" require "active_job/railtie" require "active_record/railtie" # require "active_storage/engine" @@ -14,7 +14,6 @@ # require "action_cable/engine" require "sprockets/railtie" require "rails/test_unit/railtie" -require "elasticsearch/rails/instrumentation" # Require the gems listed in Gemfile, including any gems # you've limited to :test, :development, or :production. @@ -23,7 +22,12 @@ module Gemcutter class Application < Rails::Application # Initialize configuration defaults for originally generated Rails version. - config.load_defaults 5.2 + config.load_defaults 7.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 cops shoryuken]) # Settings in config/environments/* take precedence over those specified here. # Application configuration can go into files in config/initializers @@ -40,20 +44,35 @@ class Application < Rails::Application config.i18n.available_locales = [:en, :nl, "zh-CN", "zh-TW", "pt-BR", :fr, :es, :de, :ja] config.i18n.fallbacks = [:en] - config.middleware.insert 0, Rack::UTF8Sanitizer + config.middleware.insert 0, Rack::Sanitizer config.middleware.use Rack::Attack config.middleware.use Rack::Deflater + require_relative '../lib/gemcutter/middleware/admin_auth' + config.middleware.use ::Gemcutter::Middleware::AdminAuth + config.active_record.include_root_in_json = false config.after_initialize do RubygemFs.s3! ENV["S3_PROXY"] if ENV["S3_PROXY"] end - config.plugins = [:dynamic_form] - - config.eager_load_paths << Rails.root.join("lib") config.toxic_domains_filepath = Rails.root.join("vendor", "toxic_domains_whole.txt") + + config.active_job.queue_adapter = :good_job + + config.add_autoload_paths_to_load_path = false + config.autoload_paths << "#{root}/app/views" + config.autoload_paths << "#{root}/app/views/layouts" + config.autoload_paths << "#{root}/app/views/components" + + config.active_support.cache_format_version = 7.1 + + config.action_dispatch.rescue_responses["Rack::Multipart::EmptyContentError"] = :bad_request + + config.action_dispatch.default_headers.merge!( + "Cross-Origin-Opener-Policy" => "same-origin" + ) end def self.config @@ -62,8 +81,9 @@ def self.config DEFAULT_PAGE = 1 DEFAULT_PAGINATION = 20 - EMAIL_TOKEN_EXPRIES_AFTER = 3.hours - HOST = config["host"] + EMAIL_TOKEN_EXPIRES_AFTER = 3.hours + HOST = config["host"].freeze + HOST_DISPLAY = Rails.env.production? || Rails.env.development? || Rails.env.test? ? "RubyGems.org" : "RubyGems.org #{Rails.env}" NEWS_DAYS_LIMIT = 7.days NEWS_MAX_PAGES = 10 NEWS_PER_PAGE = 10 @@ -73,9 +93,21 @@ def self.config POPULAR_DAYS_LIMIT = 70.days PROTOCOL = config["protocol"] REMEMBER_FOR = 2.weeks + SEARCH_INDEX_NAME = "rubygems-#{Rails.env}".freeze + SEARCH_NUM_REPLICAS = ENV.fetch("SEARCH_NUM_REPLICAS", 1).to_i SEARCH_MAX_PAGES = 100 # Limit max page as ES result window is upper bounded by 10_000 records STATS_MAX_PAGES = 10 STATS_PER_PAGE = 10 MAX_FIELD_LENGTH = 255 PASSWORD_VERIFICATION_EXPIRY = 10.minutes + MAX_TEXT_FIELD_LENGTH = 64_000 + OWNERSHIP_CALLS_PER_PAGE = 10 + GEM_REQUEST_LIMIT = 400 + VERSIONS_PER_PAGE = 100 + SEPARATE_ADMIN_HOST = config["separate_admin_host"] + ENABLE_DEVELOPMENT_ADMIN_LOG_IN = Rails.env.local? + MAIL_SENDER = "RubyGems.org ".freeze + PAGES = %w[ + about data download faq migrate security sponsors + ].freeze end diff --git a/config/boot.rb b/config/boot.rb index 3cda23b4db4..988a5ddc460 100644 --- a/config/boot.rb +++ b/config/boot.rb @@ -1,4 +1,4 @@ -ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) +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/brakeman.ignore b/config/brakeman.ignore new file mode 100644 index 00000000000..2a902bfc302 --- /dev/null +++ b/config/brakeman.ignore @@ -0,0 +1,52 @@ +{ + "ignored_warnings": [ + { + "warning_type": "Mass Assignment", + "warning_code": 105, + "fingerprint": "85e397bacae5462238e8ce59c0fcd1045a414e603588ae313c5156c09a623fed", + "check_name": "PermitAttributes", + "message": "Potentially dangerous key allowed for mass assignment", + "file": "app/controllers/owners_controller.rb", + "line": 101, + "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", + "code": "params.permit(:role)", + "render_path": null, + "location": { + "type": "method", + "class": "OwnersController", + "method": "update_params" + }, + "user_input": ":role", + "confidence": "Medium", + "cwe_id": [ + 915 + ], + "note": "" + }, + { + "warning_type": "Mass Assignment", + "warning_code": 105, + "fingerprint": "b57c75e671196846b60667930fc3c1a4d02122b7ec3ae5ae0cf8cecc6c1d0b63", + "check_name": "PermitAttributes", + "message": "Potentially dangerous key allowed for mass assignment", + "file": "app/controllers/api/v1/owners_controller.rb", + "line": 84, + "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", + "code": "params.permit(:role)", + "render_path": null, + "location": { + "type": "method", + "class": "Api::V1::OwnersController", + "method": "ownership_params" + }, + "user_input": ":role", + "confidence": "Medium", + "cwe_id": [ + 915 + ], + "note": "" + } + ], + "updated": "2024-09-21 16:16:50 -0700", + "brakeman_version": "6.2.1" +} diff --git a/config/brakeman.yml b/config/brakeman.yml index d9492b31e1d..26a7bbdc235 100644 --- a/config/brakeman.yml +++ b/config/brakeman.yml @@ -7,5 +7,5 @@ :output_files: - reports/brakeman/brakeman.json - reports/brakeman/brakeman.html -:rails5: true +:rails7: true :github_repo: rubygems/rubygems.org diff --git a/config/database.yml.example b/config/database.yml.example deleted file mode 100644 index bfc241051bd..00000000000 --- a/config/database.yml.example +++ /dev/null @@ -1,36 +0,0 @@ -default: &default - adapter: postgresql - encoding: utf8 - username: postgres - -development: - <<: *default - database: rubygems_development - host: localhost - password: devpassword - pool: 5 - timeout: 5000 - - -test: - <<: *default - database: rubygems_test - host: localhost - min_messages: warning - password: testpassword - pool: 5 - timeout: 5000 - -staging: - <<: *default - database: rubygems_staging - min_messages: error - pool: 30 - reconnect: true - -production: - <<: *default - database: rubygems_production - min_messages: error - pool: 30 - reconnect: true diff --git a/config/database.yml.sample b/config/database.yml.sample new file mode 100644 index 00000000000..fbc8eb0dcf3 --- /dev/null +++ b/config/database.yml.sample @@ -0,0 +1,40 @@ +default: &default + adapter: postgresql + encoding: utf8 + username: postgres + prepared_statements: false + +development: + primary: + <<: *default + database: rubygems_development + host: localhost + password: devpassword + pool: 5 + timeout: 5000 + +test: + primary: + <<: *default + database: rubygems_test + host: localhost + min_messages: warning + password: testpassword + pool: 5 + timeout: 5000 + +staging: + primary: + <<: *default + database: rubygems_staging + min_messages: error + pool: 30 + reconnect: true + +production: + primary: + <<: *default + database: rubygems_production + min_messages: error + pool: 30 + reconnect: true diff --git a/config/database.yml.ts-sample b/config/database.yml.ts-sample new file mode 100644 index 00000000000..ceffad6f109 --- /dev/null +++ b/config/database.yml.ts-sample @@ -0,0 +1,60 @@ +default: &default + adapter: postgresql + encoding: utf8 + username: postgres + prepared_statements: false + +timescale: ×cale + adapter: postgresql + encoding: utf8 + username: postgres + migrations_paths: db/downloads_migrate + port: 5434 + +development: + primary: + <<: *default + database: rubygems_development + host: localhost + password: devpassword + pool: 5 + timeout: 5000 + downloads: + <<: *timescale + database: rubygems_tsdb_development + host: localhost + password: devpassword + pool: 5 + timeout: 5000 + +test: + primary: + <<: *default + database: rubygems_test + host: localhost + min_messages: warning + password: testpassword + pool: 5 + timeout: 5000 + downloads: + <<: *timescale + database: rubygems_tsdb_test + host: localhost + min_messages: warning + password: testpassword + pool: 5 + timeout: 5000 + +production: + primary: + <<: *default + database: rubygems_production + min_messages: error + pool: 30 + reconnect: true + downloads: + <<: *timescale + database: rubygems_tsdb_production + min_messages: error + pool: 30 + reconnect: true diff --git a/config/deploy/db-migrate.yaml.erb b/config/deploy/db-migrate.yaml.erb index a445832dbb0..ebc310b43f3 100644 --- a/config/deploy/db-migrate.yaml.erb +++ b/config/deploy/db-migrate.yaml.erb @@ -5,19 +5,27 @@ metadata: name: db-migrate-<%= deployment_id %> labels: name: db-migrate-<%= deployment_id %> + tags.datadoghq.com/env: "<%= environment %>" + tags.datadoghq.com/service: rubygems.org + tags.datadoghq.com/version: <%= current_sha %> spec: activeDeadlineSeconds: 300 automountServiceAccountToken: false restartPolicy: Never containers: - name: db-migrate - image: quay.io/rubygems/rubygems.org:<%= current_sha %> + image: 048268392960.dkr.ecr.us-west-2.amazonaws.com/rubygems/rubygems.org:<%= current_sha %> args: ["rails", "db:migrate"] env: - name: RAILS_ENV value: "<%= environment %>" - name: ENV value: "<%= environment %>" + - name: DD_AGENT_HOST + valueFrom: + fieldRef: + apiVersion: v1 + fieldPath: status.hostIP - name: STATSD_IMPLEMENTATION value: "datadog" - name: STATSD_HOST @@ -54,11 +62,6 @@ spec: secretKeyRef: name: <%= environment %> key: aws_secret_access_key - - name: NEW_RELIC_LICENSE_KEY - valueFrom: - secretKeyRef: - name: <%= environment %> - key: new_relic_license_key - name: HONEYBADGER_API_KEY valueFrom: secretKeyRef: diff --git a/config/deploy/delayed_job_statsd.yaml.erb b/config/deploy/delayed_job_statsd.yaml.erb deleted file mode 100644 index 63b400513e2..00000000000 --- a/config/deploy/delayed_job_statsd.yaml.erb +++ /dev/null @@ -1,52 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: delayed-jobs-statsd - annotations: - shipit.shopify.io/restart: 'true' -spec: - selector: - matchLabels: - name: delayed-jobs-statsd - template: - metadata: - labels: - name: delayed-jobs-statsd - spec: - containers: - - name: delayed-jobs-statsd - image: dwradcliffe/delayed_job_statsd@sha256:9c501682bc5ffa84ab50461301b7871f8bb03a301a56876d74bc47c9f74442ec - env: - - name: ENV - value: "<%= environment %>" - - name: STATSD_IMPLEMENTATION - value: "datadog" - - name: STATSD_HOST - valueFrom: - fieldRef: - apiVersion: v1 - fieldPath: status.hostIP - - name: STATSD_ADDR - value: $(STATSD_HOST):8125 - - name: PG_DATABASE - valueFrom: - secretKeyRef: - name: <%= environment %> - key: database_name - - name: PG_HOST - valueFrom: - secretKeyRef: - name: <%= environment %> - key: database_host - - name: PG_USER - valueFrom: - secretKeyRef: - name: <%= environment %> - key: database_user - - name: PG_PASSWORD - valueFrom: - secretKeyRef: - name: <%= environment %> - key: database_password - securityContext: - privileged: false diff --git a/config/deploy/ingress.yaml.erb b/config/deploy/ingress.yaml.erb index cef9c6887ce..70f5fb33474 100644 --- a/config/deploy/ingress.yaml.erb +++ b/config/deploy/ingress.yaml.erb @@ -1,4 +1,4 @@ -apiVersion: extensions/v1beta1 +apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: <%= environment %> @@ -11,10 +11,13 @@ metadata: alb.ingress.kubernetes.io/target-group-attributes: load_balancing.algorithm.type=least_outstanding_requests alb.ingress.kubernetes.io/tags: Env=<%= environment %>,Service=rubygems.org alb.ingress.kubernetes.io/healthcheck-path: /internal/ping + alb.ingress.kubernetes.io/load-balancer-name: <%= environment %>-rubygems-org spec: tls: - hosts: - <%= environment %>-origin.rubygems.org - backend: - serviceName: unicorn - servicePort: 80 + defaultBackend: + service: + name: web + port: + number: 80 diff --git a/config/deploy/jobs.yaml.erb b/config/deploy/jobs.yaml.erb index c54e0727625..ef64069f77d 100644 --- a/config/deploy/jobs.yaml.erb +++ b/config/deploy/jobs.yaml.erb @@ -16,14 +16,17 @@ spec: template: metadata: annotations: - ad.datadoghq.com/unicorn.logs: '[{"source":"rails","service":"rubygems.org"}]' + ad.datadoghq.com/good-job.logs: '[{"source":"rails","service":"rubygems.org","version": <%= current_sha.dump %>}]' labels: name: jobs + tags.datadoghq.com/env: "<%= environment %>" + tags.datadoghq.com/service: rubygems.org + tags.datadoghq.com/version: <%= current_sha %> spec: containers: - - name: jobs - image: quay.io/rubygems/rubygems.org:<%= current_sha %> - args: ["rake", "jobs:work"] + - name: good-job + image: 048268392960.dkr.ecr.us-west-2.amazonaws.com/rubygems/rubygems.org:<%= current_sha %> + args: ["good_job", "start"] resources: <% if environment == 'production' %> requests: @@ -31,7 +34,7 @@ spec: memory: 1.4Gi limits: cpu: 1000m - memory: 1.6Gi + memory: 2.0Gi <% else %> requests: cpu: 200m @@ -40,11 +43,37 @@ spec: cpu: 500m memory: 1.2Gi <% end %> + ports: + - name: probe-port + containerPort: 7001 + startupProbe: + httpGet: + path: "/status/started" + port: probe-port + failureThreshold: 30 + periodSeconds: 10 + livenessProbe: + httpGet: + path: "/status/connected" + port: probe-port + failureThreshold: 1 + periodSeconds: 10 env: - name: RAILS_ENV value: "<%= environment %>" - name: ENV value: "<%= environment %>" + - name: GOOD_JOB_PROBE_PORT + value: "7001" + <% if environment != "production" %> + - name: GOOD_JOB_MAX_THREADS + value: "2" + <% end %> + - name: DD_AGENT_HOST + valueFrom: + fieldRef: + apiVersion: v1 + fieldPath: status.hostIP - name: STATSD_IMPLEMENTATION value: "datadog" - name: STATSD_HOST @@ -83,11 +112,6 @@ spec: secretKeyRef: name: <%= environment %> key: aws_secret_access_key - - name: NEW_RELIC_LICENSE_KEY - valueFrom: - secretKeyRef: - name: <%= environment %> - key: new_relic_license_key - name: HONEYBADGER_API_KEY valueFrom: secretKeyRef: @@ -113,6 +137,8 @@ spec: secretKeyRef: name: <%= environment %> key: elasticsearch_url + - name: SEARCH_NUM_REPLICAS + value: "1" - name: MEMCACHED_ENDPOINT valueFrom: secretKeyRef: @@ -133,5 +159,20 @@ spec: secretKeyRef: name: <%= environment %> key: database_url + - name: HOOK_RELAY_ACCOUNT_ID + valueFrom: + secretKeyRef: + name: <%= environment %> + key: hook_relay_account_id + - name: HOOK_RELAY_HOOK_ID + valueFrom: + secretKeyRef: + name: <%= environment %> + key: hook_relay_hook_id + - name: LAUNCH_DARKLY_SDK_KEY + valueFrom: + secretKeyRef: + name: <%= environment %> + key: launch_darkly_sdk_key securityContext: privileged: false diff --git a/config/deploy/nginx-configmap.yaml.erb b/config/deploy/nginx-configmap.yaml.erb index eabdb8d988f..fb7706c95fc 100644 --- a/config/deploy/nginx-configmap.yaml.erb +++ b/config/deploy/nginx-configmap.yaml.erb @@ -25,9 +25,12 @@ data: } limit_req_status 429; - limit_req_zone $binary_remote_addr zone=abusers:10m rate=100r/s; + limit_req_zone $binary_remote_addr zone=ui:10m rate=300r/m; + limit_req_zone $binary_remote_addr zone=abusers:10m rate=30r/s; limit_req_zone $binary_remote_addr zone=dependencyapi:10m rate=100r/s; + limit_req_zone $binary_remote_addr zone=minutely:10m rate=100r/m; + proxy_cache_path /var/lib/nginx/cache levels=1:2 keys_zone=cache-versions:10m inactive=24h max_size=100m; include /etc/nginx/conf.d/logging.conf; include /etc/nginx/sites-enabled/*; @@ -39,11 +42,16 @@ data: real_ip_recursive on; - # Fastly + # Fastly (https://api.fastly.com/public-ip-list) + set_real_ip_from '23.235.32.0/20'; + set_real_ip_from '43.249.72.0/22'; set_real_ip_from '103.244.50.0/24'; set_real_ip_from '103.245.222.0/23'; set_real_ip_from '103.245.224.0/24'; set_real_ip_from '104.156.80.0/20'; + set_real_ip_from '140.248.64.0/18'; + set_real_ip_from '140.248.128.0/17'; + set_real_ip_from '146.75.0.0/17'; set_real_ip_from '151.101.0.0/16'; set_real_ip_from '157.52.64.0/18'; set_real_ip_from '167.82.0.0/17'; @@ -52,14 +60,11 @@ data: set_real_ip_from '167.82.224.0/20'; set_real_ip_from '172.111.64.0/18'; set_real_ip_from '185.31.16.0/22'; - set_real_ip_from '199.232.0.0/16'; set_real_ip_from '199.27.72.0/21'; - set_real_ip_from '202.21.128.11/32'; - set_real_ip_from '202.21.128.12/32'; - set_real_ip_from '203.57.145.11/32'; - set_real_ip_from '203.57.145.12/32'; - set_real_ip_from '23.235.32.0/20'; - set_real_ip_from '43.249.72.0/22'; + set_real_ip_from '199.232.0.0/16'; + + set_real_ip_from '2a04:4e40::/32'; + set_real_ip_from '2a04:4e42::/32'; # AWS set_real_ip_from '172.30.0.0/16'; @@ -75,6 +80,16 @@ data: client_max_body_size 500M; + # Stop unsupported methods from getting to Puma, + # respond with a 405. + # (Puma responds with a 501 to unknown methods, which leads to us getting paged + # for what are essentially bad requests, not server errors) + # Can't use limit_except here because it doesn't work in server context, + # only location context, and we want this to apply to all locations. + if ( $request_method !~ ^(GET|HEAD|POST|PUT|DELETE|OPTIONS|PATCH)$ ) { + return 405; + } + location /api/v1/dependencies { limit_req zone=dependencyapi burst=10 nodelay; proxy_pass http://127.0.0.1:3000; @@ -83,12 +98,32 @@ data: proxy_busy_buffers_size 256k; } + location /versions { + limit_req zone=minutely burst=10 nodelay; + proxy_pass http://127.0.0.1:3000/versions; + } + + location /api/v1/search { + limit_req zone=minutely burst=10 nodelay; + proxy_pass http://127.0.0.1:3000; + } + + location /search { + limit_req zone=minutely burst=10 nodelay; + proxy_pass http://127.0.0.1:3000; + } + + location /api { + limit_req zone=abusers burst=10 nodelay; + proxy_pass http://127.0.0.1:3000; + } + location /info { proxy_pass http://127.0.0.1:3000; } <% if environment == 'staging' %> - location ~ ^/(api|internal|versions) { + location /internal { limit_req zone=abusers burst=10; proxy_pass http://127.0.0.1:3000; } @@ -101,7 +136,7 @@ data: } <% else %> location / { - limit_req zone=abusers burst=10; + limit_req zone=ui burst=5; proxy_pass http://127.0.0.1:3000; } <% end %> @@ -136,7 +171,9 @@ data: '"ssl_cipher": "$ssl_cipher"' '},' '"network": {' - '"client": {"ip": "$remote_addr"} ' + '"client": {"ip": "$remote_addr"}, ' + '"bytes_written": $bytes_sent, ' + '"body_bytes_written": $body_bytes_sent' '}' '}'; access_log /dev/stdout json; diff --git a/config/deploy/ownership-requests-notify-daily.yaml.erb b/config/deploy/ownership-requests-notify-daily.yaml.erb new file mode 100644 index 00000000000..cd80384e7e5 --- /dev/null +++ b/config/deploy/ownership-requests-notify-daily.yaml.erb @@ -0,0 +1,94 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: ownership-requests-notify-daily + labels: + name: ownership-requests-notify-daily +spec: + concurrencyPolicy: Forbid + schedule: "0 2 * * *" + jobTemplate: + spec: + template: + metadata: + labels: + name: ownership-requests-notify-daily + tags.datadoghq.com/env: "<%= environment %>" + tags.datadoghq.com/service: rubygems.org + tags.datadoghq.com/version: <%= current_sha %> + spec: + restartPolicy: "OnFailure" + containers: + - name: ownership-requests-notify-daily + image: 048268392960.dkr.ecr.us-west-2.amazonaws.com/rubygems/rubygems.org:<%= current_sha %> + args: ["rake", "ownership_request_notification:send"] + resources: + <% if environment == 'production' %> + requests: + cpu: 500m + memory: 1Gi + limits: + cpu: 2000m + memory: 3Gi + <% else %> + requests: + cpu: 200m + memory: 1Gi + limits: + cpu: 500m + memory: 2Gi + <% end %> + env: + - name: RAILS_ENV + value: "<%= environment %>" + - name: ENV + value: "<%= environment %>" + - name: DD_AGENT_HOST + valueFrom: + fieldRef: + apiVersion: v1 + fieldPath: status.hostIP + - name: STATSD_IMPLEMENTATION + value: "datadog" + - name: STATSD_HOST + valueFrom: + fieldRef: + apiVersion: v1 + fieldPath: status.hostIP + - name: STATSD_ADDR + value: $(STATSD_HOST):8125 + - name: SECRET_KEY_BASE + valueFrom: + secretKeyRef: + name: <%= environment %> + key: secret_key_base + - name: CLIENT_ID + valueFrom: + secretKeyRef: + name: <%= environment %> + key: client_id + - name: SLACK_HOOK + valueFrom: + secretKeyRef: + name: <%= environment %> + key: slack_hook + - name: HONEYBADGER_API_KEY + valueFrom: + secretKeyRef: + name: <%= environment %> + key: honeybadger_api_key + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: <%= environment %> + key: database_url + volumeMounts: + - mountPath: /app/lib/tasks/users_verify.rake + name: users-verify-rake-volume + subPath: users_verify.rake + securityContext: + privileged: false + volumes: + - name: users-verify-rake-volume + configMap: + name: users-verify-rake-file diff --git a/config/deploy/production/delayed_job_statsd.yaml.erb b/config/deploy/production/delayed_job_statsd.yaml.erb deleted file mode 120000 index a3210022f1e..00000000000 --- a/config/deploy/production/delayed_job_statsd.yaml.erb +++ /dev/null @@ -1 +0,0 @@ -../delayed_job_statsd.yaml.erb \ No newline at end of file diff --git a/config/deploy/production/ownership-requests-notify-daily.yaml.erb b/config/deploy/production/ownership-requests-notify-daily.yaml.erb new file mode 120000 index 00000000000..137a21a41cc --- /dev/null +++ b/config/deploy/production/ownership-requests-notify-daily.yaml.erb @@ -0,0 +1 @@ +../ownership-requests-notify-daily.yaml.erb \ No newline at end of file diff --git a/config/deploy/production/secrets.ejson b/config/deploy/production/secrets.ejson index eb8c99f7818..23e4cd93c0b 100644 --- a/config/deploy/production/secrets.ejson +++ b/config/deploy/production/secrets.ejson @@ -5,24 +5,29 @@ "_type": "Opaque", "data": { "secret_key_base": "EJ[1:PdZreInTWXI1FLrFAIe4j7eLOfbTVh6EWWwBL89TMAk=:i2m4ZdL6cNaw89lU8r22s403O9eEiEDl:NY18SMzycBnJwDS/hYHI3GmxqKDpLgBxGJbFhf3GgRScY2J/QEHCHv+Fl+vahDSeYEbAmgJxTUS/mpeE++FuhnWrGua9dpQpar5hr12X4CzadkQN2ef+YadRmUr97IvD10VBCUn5zjBjZOYiJQdBqwhK73Cr4jf/hWH30RJn6UCuAiPGo5H+KsjlZ7voW/Ep]", - "database_url": "EJ[1:vJNVfHEjq+SualZqTJEqL5rNL78Ci17DE9YSJxilp3g=:E/JNRRBkxjVGbziezvIv7P66QUKP6K1V:PkSExVrYsxtQX+IZG2myTQ7aWvxJzPXAfBP/98XccsaODu+LPDRsM5ijdT3uypPQ/tRhLldCF9rPXShA0Aw9iqxm85qsNQe00QTb4H/YOUNM3slC2IFJ5dxiwMEGn9bprTw8HWJyvmGVrkQLhpY0zhsurz7XOglRTMJhKr5vSMOuI9ocz5OEpYrDVeCYS4cTzgJCi+NLEa59CxsJ]", - "database_host": "EJ[1:vJNVfHEjq+SualZqTJEqL5rNL78Ci17DE9YSJxilp3g=:YACrMPdBYVmW4+imbITtKvCvzIH7c5JL:h8NQm8lrTOJxARA/PN21EcR8oU/+lliWreJksg1FbVDmwTIrBSynBbnZljC+sU11cSeri/jYyPT8AMEyJsnsSvOSMB7t7ElnCVMgvQ==]", - "database_name": "EJ[1:vza9gCGmGML5VE8xXNtzV6KCDPrgzr1QkXC5r5wMPEs=:PA2blUzMQDf9x01L48+sB6bhSqzCbZq5:2fz+i2ythOWtexG9GXQafxEPWnILHc9QI/Xm+cMdbOPtMCg=]", - "database_user": "EJ[1:vJNVfHEjq+SualZqTJEqL5rNL78Ci17DE9YSJxilp3g=:IPGoo96EvcsYH4wf2Jd+gm7T5dXGc1Kb:Besbhmr0LtKJbyMfqWawICsBa4YXKL1imqjdFofUGA==]", - "database_password": "EJ[1:vJNVfHEjq+SualZqTJEqL5rNL78Ci17DE9YSJxilp3g=:Wy84Q8DMYCMHTrxLqRX/g/npD5BjbhiX:5cMIADIK/e4nHKy0/hGYl/8G1hZVjbicVOBd1ITVeqpQl9fGBBM+6nG6mvDosg==]", + "database_url": "EJ[1:YlpB83iwFzbj2Ahs23ostET8SoTv4CYtZqHxufsgCjk=:4azvFdLW7wh8rsRIan2B4kTWCyPinJDf:+YF4ehPcRrOwHClHXzj45j9nIAlLUrg5qMhtNWOXwomvnEFZtd5k+CdyPKxSbQnaLA6h6UVVLQ7sL/JEJitu9i00ZDrCufvZBGldbEl7wQkTOTIAceA52ZU5/lS70QTKNILZIhRnEdffo8X/TsJVVyHqPdnpZtLBJKLkxIeV3uOAUI75ux3zKUDkeXqE/DLz7T+INF0ZHIXNMPCQuhrmXjspvREWE76eRAIdnOkHRuKKEBk=]", "aws_access_key_id": "EJ[1:PdZreInTWXI1FLrFAIe4j7eLOfbTVh6EWWwBL89TMAk=:KeGkWu/d8KKHzHzGpDncf6Nvp2NfVYRQ:YMhI+eSsVUJKoYJCM0zWenmcJ6CtVcLiyz6GHU2V63O6HPFx]", "aws_secret_access_key": "EJ[1:PdZreInTWXI1FLrFAIe4j7eLOfbTVh6EWWwBL89TMAk=:u6fuRpUNQaSoDWTFMn/Me+9Kr1Z+93hA:1HEeKUFo2+K5xaf9fjD6VmewC60GIaYZ93JESoOCqpGhybfk3UypbFhp0yNTsHVM7PhH1n1O56M=]", - "new_relic_license_key": "EJ[1:PdZreInTWXI1FLrFAIe4j7eLOfbTVh6EWWwBL89TMAk=:nImcbuC78uo2v2YouHTjsBCSU9hdul9k:M4N/ZtVth5k3aldEOSVnFSh6BWtUeWlog9bIDlMH7QOpqA7tVQ7Muhbz1c1CBjhgjFa9SfL4Ssg=]", "honeybadger_api_key": "EJ[1:PdZreInTWXI1FLrFAIe4j7eLOfbTVh6EWWwBL89TMAk=:PvoZeAX52WmcFsCSWiVhSF86SNd3j9SF:z5I4XA0St0psAfod3SCcwPMWYl2d330p]", "fastly_api_key": "EJ[1:PdZreInTWXI1FLrFAIe4j7eLOfbTVh6EWWwBL89TMAk=:M98nU0WSNMUuTRzN9y+01NZqgGRXTZPI:bG+dA1ngkleKfENVTvk84PL9BuPunrJlJa9lqMnuBwosD0fC6EKp4k2hhfCxIl7A]", "fastly_service_id": "EJ[1:PdZreInTWXI1FLrFAIe4j7eLOfbTVh6EWWwBL89TMAk=:C4oFJ1efUUzzBp0bHOoD9RfYhksPv9b2:8/aWHX+8FFB3xh5XOPP77/O4feoGI9zPX1cvJuijuRfl9UMxC+4=]", "_fastly_domains": "index.rubygems.org,api.rubygems.org,rubygems.org", - "elasticsearch_url": "EJ[1:PdZreInTWXI1FLrFAIe4j7eLOfbTVh6EWWwBL89TMAk=:L0W35UFL4ENDzCE9zbscqKM+5mIGjvZC:ea/Tiu6HlWBcS58gxaWsXSRMtuUqSbDO8bm5l9WLlq83WXAT+3Pea6XjpSrQUHZuA+ebTRJxur/U5iWzs8isq+/KQINJmbfHLqR7JLSEklFIYvB5WvewfAVl3sJ4lTEPjJ8nL9CkhD2i7g==]", + "elasticsearch_url": "EJ[1:K3jHI9NwKx7woYXELkNv9tdW5Mk4Ycu9iT2D0UjL7S4=:+znWhuOB+F6ZUR83YDGd/wfQsDRYlWt+:n7zPDoit3reMDH1uao2EN/VWq4T+lK4FVUCVMd//hSnIRwJMYbyedNvAaZzm6Ebgx3l8FDUI8iBPONCdf6yqqm4x0l2rO27bnDb+ccgJxe8STDQWC/wIEM/TswrYg5mLVmsi3Zgqqw+DsA==]", "memcached_endpoint": "EJ[1:PdZreInTWXI1FLrFAIe4j7eLOfbTVh6EWWwBL89TMAk=:FIeGTD+rb3bEUmaY9Bjf/iB2IhstX4wK://dMk+r6yv5mhlG1A2sms1C1RAyGvtb1UBsigE3DCPP7F7t7B4uE6CA6qHQq99VWLE+4NK4eA6bG+SyezG1QWs2yib5ziRx2GQ0qM/5OUw==]", "sendgrid_username": "EJ[1:PdZreInTWXI1FLrFAIe4j7eLOfbTVh6EWWwBL89TMAk=:C8S7bPGPGmgRuD3Fbl4YHaH3kgIRVqZ+:86aooyNFO+v8ZSLPU+rvUTVN5BiezQ==]", "sendgrid_password": "EJ[1:PdZreInTWXI1FLrFAIe4j7eLOfbTVh6EWWwBL89TMAk=:hCT3ZHaQlJd2EkX2uwxRDiKVaBvd7k0j:kTZ6xuOfijgfAuZmiOfSeJfGRooB6WR64Nn3TRVxOqxf/Mwn2iiswlXYLCAzDSmDgD6HjogLWzyZzaD02AC/Z37FIZOHLS4MyI+CMAVhUfkvHdboqA==]", "sendgrid_webhook_username": "EJ[1:knRFHU6IIu8Eak7gVYRswofSWQQT999HRv+A4uc45xc=:YuZ4F+HGjOnsN9c0jGfz4nsPXQ4XpOEe:B71d9YohOGAJIe3TPen+NdvIh6KZP+Kbo7sCWlRYB2YD7u8XRZHdMmfnaxP72A==]", - "sendgrid_webhook_password": "EJ[1:knRFHU6IIu8Eak7gVYRswofSWQQT999HRv+A4uc45xc=:B2+d7StxJq6trdx3llOsAKdfSpGStMnj:cVN68Wr3dyMCl2sfaw49RKjjz4c4dheE6q5lugNt6SQkSzxfjJ8cKKqck7mlO+zv4tsWG6aA1rPZdYY=]" + "sendgrid_webhook_password": "EJ[1:knRFHU6IIu8Eak7gVYRswofSWQQT999HRv+A4uc45xc=:B2+d7StxJq6trdx3llOsAKdfSpGStMnj:cVN68Wr3dyMCl2sfaw49RKjjz4c4dheE6q5lugNt6SQkSzxfjJ8cKKqck7mlO+zv4tsWG6aA1rPZdYY=]", + "slack_hook": "EJ[1:YVJ0VwGrcSYWmu7lQOJPUdkXYI5VBgGXLla1+Fo1eA8=:Y4LXoC+VBuYlLzbdr8YQVq68/m2gLCtg:2uF8HKXzMpZWi11ZALiPIRRvZWusZav+9ZRHrdT4HEBa3Cp5jE+bTxaMAZB70MuZX2iNRFc/a0ckEh8gZ8gaFQ0VmFxGJNujuPNTEOYmekxrUhP0q4/EgzhTM3RzQ8k=]", + "client_id": "EJ[1:YVJ0VwGrcSYWmu7lQOJPUdkXYI5VBgGXLla1+Fo1eA8=:jnplUfdfAMq/te3PyIN3SejasXCukW/g:0LsAhlffVl+6W4k5sRLWpew4D8ohT5ym3Ou+O1CsZgd/fvu16YWRpgvX345ewip0]", + "github_key": "EJ[1:dz+xczzzSxiEdCK5nfz6u2RkBOEeeOTlMD2eWIHTAg8=:3ZtgHbbvis6EJ5UymIZBWPwc+Zvf722/:gcsPiEVuVZIitCZ3t2216Jn5re2vBTyN7FElhowCKpQUglwU]", + "github_secret": "EJ[1:dz+xczzzSxiEdCK5nfz6u2RkBOEeeOTlMD2eWIHTAg8=:3bGij9eYVzE6mJ4XmIT/QBBRmnCi9nMs:8jcKV1Hl8TYTMQaQ8SV+agQ6c9UA9lb1DHKy2J/W//gdlJ2r/r4qlKeI/zoWMX2e461aB1Dx7XQ=]", + "avo_license_key": "EJ[1:6oWEkRT4PhQJLxAVEt3F66gOvAd2bB/J7cF+DpFv4Gk=:atX3EOd7BdoSBqJFPaqASO6/u6R2MLlR:G4TyGlkc1EZWpUJ0mQTS9Bw1O5V9Hww3DZtV+MQuDg9pzw8n7frV+GQek4wDqI29QLk0Kg==]", + "datadog_csp_api_key": "EJ[1:MJLOVnheQZD8mqT8Q5xlN8u6Qd9eH4sbO1kcsg/TTR4=:0eAj1g7msiS86qexeMNsWU+1vy3pXSKK:4YT1qDTiBirwkNsbNdhN90lPlF51qiMNChue7uMpEYuAiKTI3TnwOWJd7sk0PmARVsIn]", + "hook_relay_account_id": "EJ[1:Px+UVqbFzeNGqkAlgQ9Vz00tXWKza1XmLLHD/iQT5wM=:JsT1PTsSpMaxE0FV7ODyJ5E1FQAzv9v/:tMUgIFIUJH+WhT+kHS5pne5Uw9qvdLqLd2aAfJ9EehzIYpH6Bl6EQg==]", + "hook_relay_hook_id": "EJ[1:Px+UVqbFzeNGqkAlgQ9Vz00tXWKza1XmLLHD/iQT5wM=:Vt1tMd8/9ZhSHL80yrWIWUy2wMOh8Iqt:MUwOcVrjWl7i8RRNg68Sb/Fa+RYsLL54yc+adRK84i5jWWa9fmSgRg==]", + "launch_darkly_sdk_key": "EJ[1:RM2yVm4yMZPQR0lgPaM4pw8dBEvD3rts+nLnn9E7ZRs=:mR2ODNG9w0B71T5ivgrKahW0vm5QDWjv:NQTLulKnLIbRtmxOXUWM1LVQ3sJzoB9C6eslf6Y8WHYJJTWeuTgfRvpcQraZf0rC+eE/guR2d/U=]", + "rubygems_proxy_token": "EJ[1:Zi0QsZH550MKhxfxIghQiwCidie/1S7P0b8T7di4/jQ=:OJYa/K8pVTordk7kGpwif2gL/Wm8hZPX:N+m4TcsVfo4A8zuzgIse0lgspJo3HPQvdlNrbktxaK9YyfuQFVIiVSrGGRf0kB1y]" } } } diff --git a/config/deploy/production/unicorn.yaml.erb b/config/deploy/production/unicorn.yaml.erb deleted file mode 120000 index f6653c7363e..00000000000 --- a/config/deploy/production/unicorn.yaml.erb +++ /dev/null @@ -1 +0,0 @@ -../unicorn.yaml.erb \ No newline at end of file diff --git a/config/deploy/production/users-verify-daily.yaml.erb b/config/deploy/production/users-verify-daily.yaml.erb new file mode 120000 index 00000000000..f569ab0d84d --- /dev/null +++ b/config/deploy/production/users-verify-daily.yaml.erb @@ -0,0 +1 @@ +../users-verify-daily.yaml.erb \ No newline at end of file diff --git a/config/deploy/production/web.yaml.erb b/config/deploy/production/web.yaml.erb new file mode 120000 index 00000000000..ab677473d3c --- /dev/null +++ b/config/deploy/production/web.yaml.erb @@ -0,0 +1 @@ +../web.yaml.erb \ No newline at end of file diff --git a/config/deploy/service.yaml.erb b/config/deploy/service.yaml.erb index c032f317836..780282d1e79 100644 --- a/config/deploy/service.yaml.erb +++ b/config/deploy/service.yaml.erb @@ -1,15 +1,16 @@ apiVersion: v1 kind: Service metadata: - name: unicorn + name: web labels: - name: unicorn + name: web annotations: service.beta.kubernetes.io/aws-load-balancer-internal: 0.0.0.0/0 + service.beta.kubernetes.io/aws-load-balancer-name: <%= environment %>-rubygems-org spec: type: LoadBalancer ports: - port: 80 targetPort: 8080 selector: - name: unicorn + name: web diff --git a/config/deploy/shoryuken.yaml.erb b/config/deploy/shoryuken.yaml.erb index 34c259d32cf..dccf281c773 100644 --- a/config/deploy/shoryuken.yaml.erb +++ b/config/deploy/shoryuken.yaml.erb @@ -16,13 +16,16 @@ spec: template: metadata: annotations: - ad.datadoghq.com/unicorn.logs: '[{"source":"rails","service":"rubygems.org"}]' + ad.datadoghq.com/shoryuken.logs: '[{"source":"rails","service":"rubygems.org","version": <%= current_sha.dump %>}]' labels: name: shoryuken + tags.datadoghq.com/env: "<%= environment %>" + tags.datadoghq.com/service: rubygems.org + tags.datadoghq.com/version: <%= current_sha %> spec: containers: - name: shoryuken - image: quay.io/rubygems/rubygems.org:<%= current_sha %> + image: 048268392960.dkr.ecr.us-west-2.amazonaws.com/rubygems/rubygems.org:<%= current_sha %> args: ["bin/shoryuken-worker"] resources: <% if environment == 'production' %> @@ -45,6 +48,11 @@ spec: value: "<%= environment %>" - name: ENV value: "<%= environment %>" + - name: DD_AGENT_HOST + valueFrom: + fieldRef: + apiVersion: v1 + fieldPath: status.hostIP - name: STATSD_IMPLEMENTATION value: "datadog" - name: STATSD_HOST @@ -83,11 +91,6 @@ spec: secretKeyRef: name: <%= environment %> key: aws_secret_access_key - - name: NEW_RELIC_LICENSE_KEY - valueFrom: - secretKeyRef: - name: <%= environment %> - key: new_relic_license_key - name: HONEYBADGER_API_KEY valueFrom: secretKeyRef: @@ -113,6 +116,8 @@ spec: secretKeyRef: name: <%= environment %> key: elasticsearch_url + - name: SEARCH_NUM_REPLICAS + value: "1" - name: MEMCACHED_ENDPOINT valueFrom: secretKeyRef: diff --git a/config/deploy/staging/delayed_job_statsd.yaml.erb b/config/deploy/staging/delayed_job_statsd.yaml.erb deleted file mode 120000 index a3210022f1e..00000000000 --- a/config/deploy/staging/delayed_job_statsd.yaml.erb +++ /dev/null @@ -1 +0,0 @@ -../delayed_job_statsd.yaml.erb \ No newline at end of file diff --git a/config/deploy/staging/ownership-requests-notify-daily.yaml.erb b/config/deploy/staging/ownership-requests-notify-daily.yaml.erb new file mode 120000 index 00000000000..137a21a41cc --- /dev/null +++ b/config/deploy/staging/ownership-requests-notify-daily.yaml.erb @@ -0,0 +1 @@ +../ownership-requests-notify-daily.yaml.erb \ No newline at end of file diff --git a/config/deploy/staging/secrets.ejson b/config/deploy/staging/secrets.ejson index 3c87b59b4d1..ed0a20bd927 100644 --- a/config/deploy/staging/secrets.ejson +++ b/config/deploy/staging/secrets.ejson @@ -5,24 +5,29 @@ "_type": "Opaque", "data": { "secret_key_base": "EJ[1:d8Qpc6QSyiCh7sBlZ3VzKenIldRrWaFh8nE/ThE6cio=:X57Fr3d7YC/hdbuJYu/xrbglh7C3g2eF:L0jHynvq98zSuGHlVsH2DTTxitG1enTc4T8N0qEIc+wP3cxG/dWVdo5kCRdeufJfi+B1rv38DkKWW4/Cc3QjJQzN9awHVOK9KZcSTUdUqFlm7Kk5fai4FsIPgHTyFQLyTta8iubIrDNuZgpw9t3NZ6KkRwAkFsLLy2TaoICcEEQFgesiyMWVoXhun1kJnbQp]", - "database_url": "EJ[1:jc6POVuZ4ZKMywMSfQKGYRf5BDx9g74OeOj7BPKK0zk=:tzwnJdAYjq4xx5O+MksI3x+n1Uhrj8Dd:wk+q/W8FDss/DiLHYaZuFkPSDbhPxGNaAp2WtsJRCsn997FJMfBGBCpUVDG6LkmAOU80KPZZVKtmRyidth+ebpsGIqE8df05epB8u309BATW4ukTs6SgtyMJppeiCee2pdw5rkUMJyq1kn7dFEirzK0DPya6rBFfU/tRtqxZmxt6mKmgl0E5uBAWQDMKtWYJ]", - "database_host": "EJ[1:jc6POVuZ4ZKMywMSfQKGYRf5BDx9g74OeOj7BPKK0zk=:WiWMFlgBXQ36CIkukqRny72XiL89j0Tx:E871lGJx51Ion5dciAAXc1xli9A/LjYRr0y8irPINBcwJD5ksXZNERzl/S9KPI6lYF4UqIukVlW461J739wImYYn8kcH890s5g==]", - "database_name": "EJ[1:zOeecJh9Wi8biN98qphwyHYhQCwm2FhQLUZUFuByKys=:g7uCsEorNp7Vt+XHLR52brKx4C/nU8Sd:GaJB6H+arIxkRZf5WdDt+7O/azbOxMHlEPOP1OTYdYU=]", - "database_user": "EJ[1:jc6POVuZ4ZKMywMSfQKGYRf5BDx9g74OeOj7BPKK0zk=:6IBDKgXJtCtt0Uix9nEXJKIbcDipus7n:a2MrfJJ/lX57Iyvx3g9AMn5r8GYncH0Bzu0Nw1iAqQ==]", - "database_password": "EJ[1:jc6POVuZ4ZKMywMSfQKGYRf5BDx9g74OeOj7BPKK0zk=:yAskstgctoYpHZUlES4VOWzvYJRHmSv/:QYab/b78NfNeXsoYS0ZA9yoTjYKHerV1Bat4tVt3h0Vp5vA1xNKCTg==]", + "database_url": "EJ[1:YgZwg5OE85AYOXI5JlQZR0DWuFDNZ8KN7SE63miKa0Q=:9ilGhNQLnwjsmQzlXoMu1c6ftrnPqyVm:xa6eHJHHb6jGuGaWobjncjsebXwovpME5ht9c8sMdbu4nr8NQoKiRyY71yk34CFA+UabdTYIHX6PFtWPCohLHRwlxvCWQp7a6QH2+TpCygSHoIoSSQ2ifcXGHX45ZHwYrX8We1L367wubpcB/Rx1s49gofGyNMO+O51/W8nYUiWY7HI0f/SO6gal23eSGNeW0qTGhauwlxCtJ30RlOUOaIAXwMJl+dsQkorKjkM=]", "aws_access_key_id": "EJ[1:jnrbdGY2s+ZVCF8BStxF4ZGp1yhxk+x/sXxH8x32qmg=:OLx6cKpU43qYM+Fsa8FeC5Bnx0jbIB68:n9U9IUwaWpnbrLIkjwP7erHefKdX1/08kSTvSmBeuq9+0YQ4]", "aws_secret_access_key": "EJ[1:jnrbdGY2s+ZVCF8BStxF4ZGp1yhxk+x/sXxH8x32qmg=:uMtyxPC2DC0BgsYRhOWGCdaom6OroJqI:drTaFTjJ2uhfJn+aPgYTQ4hp1GdLS3Os2PfJVn1BevaukzzcsI9xVp2xS0umzutnQbmmdISOgYg=]", - "new_relic_license_key": "EJ[1:jnrbdGY2s+ZVCF8BStxF4ZGp1yhxk+x/sXxH8x32qmg=:+xYXmnYNjopnCsSP/Ak5WqwhKeg1HoD1:dBst39O+zK1S+NSyWeh07qAYdRrBn2N3cB5v82lksOSrJ4RaA5xxu69UsQiGz0kkdAhOWfwj98o=]", "honeybadger_api_key": "EJ[1:jnrbdGY2s+ZVCF8BStxF4ZGp1yhxk+x/sXxH8x32qmg=:TD1e42m2hCnraaRigNZZZao9WKF9S9Nf:Myj5m7maDkOC2GbolUskZ2F+zmRscHE8]", "fastly_api_key": "EJ[1:jnrbdGY2s+ZVCF8BStxF4ZGp1yhxk+x/sXxH8x32qmg=:Hig6V1MQP4qgUM0ONuSJ1baySFEjBUtv:38lnkxBDiihzxIKy1qRFlNIkFVK6w77KHkrKAFv23Bcn/Z3h13NjXZO/tkr27AcV]", "fastly_service_id": "EJ[1:dMyBt/+IWFvyZkXJmQMOmef2menhqHv6PFoaTCCjUks=:gJyo+Fk4j9bEbYr+Jl7zv0PmenMl2zF7:zkButJ5HOF+6hN4R0GOzPLtXv7tzqP44EUgUGRMqByrG0dRHY10=]", "_fastly_domains": "staging.rubygems.org", - "elasticsearch_url": "EJ[1:Xk79y8wlVtjKFyZbEE5AXxm2/u3S32Y+3ZDYEd7qqSA=:md53NdJnx5PVn95M4NAotzMGZ06ub5wf:pBIlOJ3GHAe1PFeoUzQ0rLQ/uuT/rkXDpOZuJiltcuE30QIdqfmVhT7BoGKl0TRAO6EihH22u46hUarfZugyHZoJN71AD8YngxFgYTFMCH4VXEKEj6YNow6lG+3gshupYK7V9YIP+Bu9]", + "elasticsearch_url": "EJ[1:uKvVLrtokYQZAf4y+Iqxk4UuF/o5N835453+L8Ogcxs=:hio79tioW/oZM0ZTJlLRqtQIQLG5V2RN:11Re9VUIFjbzvBVT2TUgYk0D/f3tTMcE3Zb7uA0hU2+izp9Di10ajftelhWH/FoUHCwIbkmj9La3jSJw/AdMwBtgaOGaU4CTFT5ZZKLybArAwmONl4j2TcWTt2GFiruDH8uVTp1aEg==]", "memcached_endpoint": "EJ[1:Xk79y8wlVtjKFyZbEE5AXxm2/u3S32Y+3ZDYEd7qqSA=:EMi4FLAdV9tWQ8IevfUfr75o6pTo0GAC:ylZl7qFloCf1iUqtEyDsam2ZsEUYcws56jQiR3mT1quTlNQr38fDz5MjeNhMAQfbdGY8KnC2v50Vs1wb1AuKbRUNwU9bGO9pl3E2]", "sendgrid_username": "EJ[1:Xk79y8wlVtjKFyZbEE5AXxm2/u3S32Y+3ZDYEd7qqSA=:SBrWz77BfZ7BECNMAwhSB+MrfezIsjl4:1vc/AeDn3zWbEyseu88JyUc6rHV6yw==]", "sendgrid_password": "EJ[1:Xk79y8wlVtjKFyZbEE5AXxm2/u3S32Y+3ZDYEd7qqSA=:iAr4Xsjv0weAkakwQqTFiItVQ2IF7Gq2:jm47iHFp0zthpMxHwcH+tWEN7hu85qxvqN7JTGMUSnkRZCdmt/J2ljE6AqpwOk6vHleaXUPUwhsapZv0Ivk42a7pziVLUUxiY3P1vYZQfzKp+WsVqQ==]", "sendgrid_webhook_username": "EJ[1:KAN7c51q2VWDrnvvVz2B0EuxBXk67FXswztYOJHRdm0=:elop/93FDinCvVXMwDT9eRNVkQkNe3Cg:VHnaUh3gqhrkbTW76SetCcDQjCFTxtviu+bbYcMFoTPb60QQH3131dmgV2zSWA==]", - "sendgrid_webhook_password": "EJ[1:KAN7c51q2VWDrnvvVz2B0EuxBXk67FXswztYOJHRdm0=:Jc7/IHv1SPsVn1pje8OZFpyI0+sRI9FQ:79/96QQJ3kfET2h2eM2eSUh0ZI1e/odfGzo61HaafHIgNeanDK3HmlDClJczhFr4kfjBimjeaab9Cp8=]" + "sendgrid_webhook_password": "EJ[1:KAN7c51q2VWDrnvvVz2B0EuxBXk67FXswztYOJHRdm0=:Jc7/IHv1SPsVn1pje8OZFpyI0+sRI9FQ:79/96QQJ3kfET2h2eM2eSUh0ZI1e/odfGzo61HaafHIgNeanDK3HmlDClJczhFr4kfjBimjeaab9Cp8=]", + "slack_hook": "EJ[1:G3QtTGP/Pf0HRv8+b6zf3xI3TmNiOPmidA24ZSgQgjU=:S9UvWTqU+qaXRCbEcjienEmbQaUwITmN:LdJ3++/9VvVSMt/7loPN0hVlzBQquUbb3BGuZMtIw5wWfbtOOEoNVaK7kuKgI0g1yxFukuPrBB89WQIPxRVJZTRnptaqPyJ8XuZI/2kgm60YxccuiOA6wep2PLWfSHI=]", + "client_id": "EJ[1:G3QtTGP/Pf0HRv8+b6zf3xI3TmNiOPmidA24ZSgQgjU=:AZtoluRkpeCK96i3FxPgamAkAlwRuTKq:NrpTfXVR1rAA0UyJzYVmCvJvQ5lr/VLyAoHDz0IY0cmC0aJLO8HWKsAYWq3T4w2D]", + "github_key": "EJ[1:w54WPX1mXZxgJoUu4zPMVMd5zCWl0rbbfuyqcvJOP1o=:XX+D9g9OUcALhAK1aRB5uK4hmUOhmq+a:kEEf22rN2vnyXE2I5me5Fc+2rCu070VA8IpcsbeJ8Xh/3261]", + "github_secret": "EJ[1:w54WPX1mXZxgJoUu4zPMVMd5zCWl0rbbfuyqcvJOP1o=:0aEWndKlt5UpeGXSNfX8jWtc5UqnKk1g:MmBDQQEzb9tUaxVIL7bUUBbg2/UvhyvbnHcvlfqWztdSVHAFuvGe+tVtHjEdCdMzrJzG5d6UVBo=]", + "avo_license_key": "EJ[1:TD6sa+BG2xSLyZddF8CUdq8egeXQfz1oeMaSD/DV/z8=:iXP5X+kGqtPPjluF93iyxTRiwA4sq5ch:My9uCrd+KqROpd1l3x8HgQYvi/gRs+FBQ41IdmqouwbVJBRipuHotXYGHBcqOtvFwpk1/w==]", + "datadog_csp_api_key": "EJ[1:M1xidXJHz2i/vKthLOnwYbEnkFjYneq+d6Ryj6TXdFQ=:fxMZfI3MptOYF/hXcrnrWK/SDTU1SzZC:Gn807pB1wSTTNIyTJ5cpGCvN5+vplIZUxi/a8/s+UFDJE52zeM5KyhvmK6f0J8mDa2vh]", + "hook_relay_account_id": "EJ[1:JT93bSSDxXR0tIyvuhE4hVVEav3DdVDYtCuhLTlwwUw=:OUkdjS28VFFsXWnK0c2zBubgen83JLIZ:r9s3A3e3UuNrYRelD3HzIKsNvEGlGhvKmZayZt7wZGHrxB+yTb8gQQ==]", + "hook_relay_hook_id": "EJ[1:JT93bSSDxXR0tIyvuhE4hVVEav3DdVDYtCuhLTlwwUw=:n1YqUGXM5FdcwoAJjIXXgHxWqiF/voeL:sj7Gbn405v2hw4CKkU6N7Fhgb3J/5RnrAZpp+Xcn61ZTNJq0qFXBiA==]", + "launch_darkly_sdk_key": "EJ[1:4JArBqOhUkobGC64JyCBRb/ceJYjVQ0E0EA3xMzo1mQ=:aBhPiBN5Tw168oFguBxtcehteIzHlRi4:bKn0Qxs1fdJklK0SVA0ngAg9C1lMWtaXnh/HP9ohYd6tu1vLvjhj7svdRVVugeKaXm/+Dslf+E8=]", + "rubygems_proxy_token": "EJ[1:x9FURsKxlgp9gfXiLojWsrNgW318z5iiKvDE4459F30=:qjWeIvg6d/YH1CLE9Sf4bzp+ACCjRIcA:rytgZ9foMBXodqn+NBQ7SxrQ3rCN2+8CwWvjmPkXLUraT/wtj1KxC7JnXG3b9Kd0]" } }, "nginx-basic-auth": { diff --git a/config/deploy/staging/unicorn.yaml.erb b/config/deploy/staging/unicorn.yaml.erb deleted file mode 120000 index f6653c7363e..00000000000 --- a/config/deploy/staging/unicorn.yaml.erb +++ /dev/null @@ -1 +0,0 @@ -../unicorn.yaml.erb \ No newline at end of file diff --git a/config/deploy/staging/web.yaml.erb b/config/deploy/staging/web.yaml.erb new file mode 120000 index 00000000000..ab677473d3c --- /dev/null +++ b/config/deploy/staging/web.yaml.erb @@ -0,0 +1 @@ +../web.yaml.erb \ No newline at end of file diff --git a/config/deploy/users-verify-daily.yaml.erb b/config/deploy/users-verify-daily.yaml.erb new file mode 100644 index 00000000000..a59f159c404 --- /dev/null +++ b/config/deploy/users-verify-daily.yaml.erb @@ -0,0 +1,94 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: users-verify-daily + labels: + name: users-verify-daily +spec: + concurrencyPolicy: Forbid + schedule: "0 12 * * *" + jobTemplate: + spec: + template: + metadata: + labels: + name: users-verify-daily + tags.datadoghq.com/env: "<%= environment %>" + tags.datadoghq.com/service: rubygems.org + tags.datadoghq.com/version: <%= current_sha %> + spec: + restartPolicy: "OnFailure" + containers: + - name: users-verify-daily + image: 048268392960.dkr.ecr.us-west-2.amazonaws.com/rubygems/rubygems.org:<%= current_sha %> + args: ["rake", "users:verify"] + resources: + <% if environment == 'production' %> + requests: + cpu: 500m + memory: 1Gi + limits: + cpu: 2000m + memory: 3Gi + <% else %> + requests: + cpu: 200m + memory: 1Gi + limits: + cpu: 500m + memory: 2Gi + <% end %> + env: + - name: RAILS_ENV + value: "<%= environment %>" + - name: ENV + value: "<%= environment %>" + - name: DD_AGENT_HOST + valueFrom: + fieldRef: + apiVersion: v1 + fieldPath: status.hostIP + - name: STATSD_IMPLEMENTATION + value: "datadog" + - name: STATSD_HOST + valueFrom: + fieldRef: + apiVersion: v1 + fieldPath: status.hostIP + - name: STATSD_ADDR + value: $(STATSD_HOST):8125 + - name: SECRET_KEY_BASE + valueFrom: + secretKeyRef: + name: <%= environment %> + key: secret_key_base + - name: CLIENT_ID + valueFrom: + secretKeyRef: + name: <%= environment %> + key: client_id + - name: SLACK_HOOK + valueFrom: + secretKeyRef: + name: <%= environment %> + key: slack_hook + - name: HONEYBADGER_API_KEY + valueFrom: + secretKeyRef: + name: <%= environment %> + key: honeybadger_api_key + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: <%= environment %> + key: database_url + volumeMounts: + - mountPath: /app/lib/tasks/users_verify.rake + name: users-verify-rake-volume + subPath: users_verify.rake + securityContext: + privileged: false + volumes: + - name: users-verify-rake-volume + configMap: + name: users-verify-rake-file diff --git a/config/deploy/versions-list-update-monthly.yaml.erb b/config/deploy/versions-list-update-monthly.yaml.erb index 784769c2774..55fcf2e1368 100644 --- a/config/deploy/versions-list-update-monthly.yaml.erb +++ b/config/deploy/versions-list-update-monthly.yaml.erb @@ -1,4 +1,4 @@ -apiVersion: batch/v1beta1 +apiVersion: batch/v1 kind: CronJob metadata: name: versions-list-update-monthly @@ -13,11 +13,14 @@ spec: metadata: labels: name: versions-list-update-monthly + tags.datadoghq.com/env: "<%= environment %>" + tags.datadoghq.com/service: rubygems.org + tags.datadoghq.com/version: <%= current_sha %> spec: restartPolicy: "OnFailure" containers: - name: versions-list-update-monthly - image: quay.io/rubygems/rubygems.org:<%= current_sha %> + image: 048268392960.dkr.ecr.us-west-2.amazonaws.com/rubygems/rubygems.org:<%= current_sha %> args: ["rake", "compact_index:update_versions_file"] resources: <% if environment == 'production' %> @@ -40,6 +43,11 @@ spec: value: "<%= environment %>" - name: ENV value: "<%= environment %>" + - name: DD_AGENT_HOST + valueFrom: + fieldRef: + apiVersion: v1 + fieldPath: status.hostIP - name: STATSD_IMPLEMENTATION value: "datadog" - name: STATSD_HOST @@ -76,11 +84,6 @@ spec: secretKeyRef: name: <%= environment %> key: aws_secret_access_key - - name: NEW_RELIC_LICENSE_KEY - valueFrom: - secretKeyRef: - name: <%= environment %> - key: new_relic_license_key - name: HONEYBADGER_API_KEY valueFrom: secretKeyRef: diff --git a/config/deploy/unicorn.yaml.erb b/config/deploy/web.yaml.erb similarity index 69% rename from config/deploy/unicorn.yaml.erb rename to config/deploy/web.yaml.erb index 6a715dfeb1d..ba6c2c32020 100644 --- a/config/deploy/unicorn.yaml.erb +++ b/config/deploy/web.yaml.erb @@ -1,32 +1,35 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: unicorn + name: web annotations: shipit.shopify.io/restart: 'true' spec: strategy: type: RollingUpdate rollingUpdate: - maxSurge: 100% - maxUnavailable: 0% + maxSurge: 25% + maxUnavailable: 25% selector: matchLabels: - name: unicorn + name: web template: metadata: annotations: - ad.datadoghq.com/unicorn.logs: '[{"source":"rails","service":"rubygems.org"}]' + ad.datadoghq.com/puma.logs: '[{"source":"rails","service":"rubygems.org","version": <%= current_sha.dump %>}]' labels: - name: unicorn + name: web + tags.datadoghq.com/env: "<%= environment %>" + tags.datadoghq.com/service: rubygems.org + tags.datadoghq.com/version: <%= current_sha %> spec: containers: - - name: unicorn - image: quay.io/rubygems/rubygems.org:<%= current_sha %> - args: ["unicorn_rails", "-E", "<%= environment %>", "-c", "/app/config/unicorn.conf"] + - name: puma + image: 048268392960.dkr.ecr.us-west-2.amazonaws.com/rubygems/rubygems.org:<%= current_sha %> + args: ["puma", "--environment", "<%= environment %>", "--config", "/app/config/puma.rb"] ports: - containerPort: 3000 - name: http-unicorn + name: http-puma readinessProbe: httpGet: path: /internal/ping @@ -58,6 +61,15 @@ spec: value: "<%= environment %>" - name: ENV value: "<%= environment %>" + - name: WEB_CONCURRENCY + value: "<%= environment == 'production' ? 8 : 2 %>" + - name: RAILS_MAX_THREADS + value: "5" + - name: DD_AGENT_HOST + valueFrom: + fieldRef: + apiVersion: v1 + fieldPath: status.hostIP - name: STATSD_IMPLEMENTATION value: "datadog" - name: STATSD_HOST @@ -98,11 +110,6 @@ spec: secretKeyRef: name: <%= environment %> key: aws_secret_access_key - - name: NEW_RELIC_LICENSE_KEY - valueFrom: - secretKeyRef: - name: <%= environment %> - key: new_relic_license_key - name: HONEYBADGER_API_KEY valueFrom: secretKeyRef: @@ -128,6 +135,8 @@ spec: secretKeyRef: name: <%= environment %> key: elasticsearch_url + - name: SEARCH_NUM_REPLICAS + value: "<%= environment == 'production' ? 2 : 1 %>" - name: MEMCACHED_ENDPOINT valueFrom: secretKeyRef: @@ -153,9 +162,51 @@ spec: secretKeyRef: name: <%= environment %> key: sendgrid_webhook_password + - name: GITHUB_KEY + valueFrom: + secretKeyRef: + name: <%= environment %> + key: github_key + - name: GITHUB_SECRET + valueFrom: + secretKeyRef: + name: <%= environment %> + key: github_secret + - name: AVO_LICENSE_KEY + valueFrom: + secretKeyRef: + name: <%= environment %> + key: avo_license_key + - name: DATADOG_CSP_API_KEY + valueFrom: + secretKeyRef: + name: <%= environment %> + key: datadog_csp_api_key + - name: HOOK_RELAY_ACCOUNT_ID + valueFrom: + secretKeyRef: + name: <%= environment %> + key: hook_relay_account_id + - name: HOOK_RELAY_HOOK_ID + valueFrom: + secretKeyRef: + name: <%= environment %> + key: hook_relay_hook_id + - name: LAUNCH_DARKLY_SDK_KEY + valueFrom: + secretKeyRef: + name: <%= environment %> + key: launch_darkly_sdk_key + - name: RUBYGEMS_PROXY_TOKEN + valueFrom: + secretKeyRef: + name: <%= environment %> + key: rubygems_proxy_token <% if environment == 'staging' %> - name: DISABLE_SIGNUP value: "true" + - name: RAILS_LOG_LEVEL + value: "info" <% end %> - name: DATABASE_URL valueFrom: @@ -169,12 +220,8 @@ spec: exec: command: ["sleep", "25"] - name: nginx - image: nginx:1.11.10 + image: nginx:1.25.2 imagePullPolicy: IfNotPresent - lifecycle: - preStop: - exec: - command: ["sleep", "20"] ports: - containerPort: 8080 name: http-nginx @@ -201,6 +248,8 @@ spec: readOnly: true - name: nginxlog mountPath: /var/log/nginx + - name: nginxcache + mountPath: /var/lib/nginx/cache <% if environment == 'staging' %> - name: nginxbasicauth mountPath: /etc/nginxbasicauth @@ -218,6 +267,17 @@ spec: port: 80 initialDelaySeconds: 5 periodSeconds: 5 + lifecycle: + # https://blog.gruntwork.io/delaying-shutdown-to-wait-for-pod-deletion-propagation-445f779a8304 + preStop: + exec: + command: [ + "sh", "-c", + # Introduce a delay to the shutdown sequence to wait for the + # pod eviction event to propagate. Then, gracefully shutdown + # nginx. + "sleep 20 && /usr/sbin/nginx -s quit", + ] volumes: - name: nginxconfig configMap: @@ -231,6 +291,8 @@ spec: path: sites-enabled/<%= environment %>.ruybgems.conf - name: nginxlog emptyDir: {} + - name: nginxcache + emptyDir: {} <% if environment == 'staging' %> - name: nginxbasicauth secret: diff --git a/config/environments/development.rb b/config/environments/development.rb index f4d2df04ddb..985853896bd 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -1,4 +1,3 @@ -require_relative "../../lib/middleware/hostess" require "active_support/core_ext/integer/time" Rails.application.configure do @@ -7,7 +6,7 @@ # In the development environment your application's code is reloaded any time # it changes. This slows down response time but is perfect for development # since you don't have to restart the web server when you make code changes. - config.cache_classes = false + config.enable_reloading = true # Do not eager load code on boot. config.eager_load = false @@ -15,29 +14,35 @@ # Show full error reports. config.consider_all_requests_local = true + # Enable server timing. + config.server_timing = true + # Enable/disable caching. By default caching is disabled. # Run rails dev:cache to toggle caching. - if Rails.root.join('tmp', 'caching-dev.txt').exist? + if Rails.root.join("tmp/caching-dev.txt").exist? config.action_controller.perform_caching = true config.action_controller.enable_fragment_cache_logging = true config.cache_store = :mem_cache_store, { compress: true, compression_min_size: 524_288 } - config.public_file_server.headers = { - 'Cache-Control' => "public, max-age=#{2.days.to_i}" - } + config.public_file_server.headers = { "Cache-Control" => "public, max-age=#{2.days.to_i}" } else config.action_controller.perform_caching = false config.cache_store = :null_store end - # Don't care if the mailer can't send. - config.action_mailer.raise_delivery_errors = false + config.action_mailer.delivery_method = :letter_opener_web + config.action_mailer.perform_deliveries = true + config.action_mailer.raise_delivery_errors = true + + # Disable caching for Action Mailer templates even if Action Controller + # caching is enabled. config.action_mailer.perform_caching = false config.action_mailer.default_url_options = { host: Gemcutter::HOST, + port: ENV.fetch("PORT", "3000"), protocol: Gemcutter::PROTOCOL } # Print deprecation notices to the Rails logger. @@ -55,6 +60,9 @@ # Highlight code that triggered database queries in logs. config.active_record.verbose_query_logs = true + # Highlight code that enqueued background job in logs. + config.active_job.verbose_enqueue_logs = true + # Debug mode disables concatenation and preprocessing of assets. # This option may cause significant delays in view rendering with a large # number of complex assets. @@ -66,31 +74,56 @@ # Raises error for missing translations. # config.i18n.raise_on_missing_translations = true - config.middleware.use Hostess + require_relative "../../lib/gemcutter/middleware/hostess" + config.middleware.use Gemcutter::Middleware::Hostess + # Annotate rendered view with file names. - # config.action_view.annotate_rendered_view_with_filenames = true + 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 + + # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. + config.generators.apply_rubocop_autocorrect_after_generate! # Use an evented file watcher to asynchronously detect changes in source code, # routes, locales, etc. This feature depends on the listen gem. config.file_watcher = ActiveSupport::EventedFileUpdateChecker + # By default, keep rails logs looking like standard rails logs + # (multiple lines per request, no timestamp/thread/process/level/logger name, etc) + enable_semantic_log_format = ENV['ENABLE_SEMANTIC_LOG_FORMAT'].present? + config.rails_semantic_logger.semantic = false + config.rails_semantic_logger.started = !enable_semantic_log_format + config.rails_semantic_logger.processing = !enable_semantic_log_format + config.rails_semantic_logger.rendered = !enable_semantic_log_format + unless enable_semantic_log_format + require 'rails_development_log_formatter' + config.semantic_logger.add_appender(io: $stdout, formatter: RailsDevelopmentLogFormatter.new) + config.rails_semantic_logger.format = RailsDevelopmentLogFormatter.new + end + # Rubygems.org checks for the presence of an env variable called PROFILE that # switches several settings to a more "production-like" value for profiling # and benchmarking the application locally. All changes you make to the app # will require restart. if ENV['PROFILE'] - config.cache_classes = true + config.enable_reloading = false config.eager_load = true - config.logger = ActiveSupport::Logger.new($stdout) config.log_level = :info + config.rails_semantic_logger.format = :json + config.rails_semantic_logger.semantic = true + config.rails_semantic_logger.started = false + config.rails_semantic_logger.processing = false + config.rails_semantic_logger.rendered = false config.public_file_server.enabled = true config.public_file_server.headers = { 'Cache-Control' => 'max-age=315360000, public', 'Expires' => 'Thu, 31 Dec 2037 23:55:55 GMT' } - config.assets.js_compressor = :uglifier + config.assets.js_compressor = :terser config.assets.css_compressor = :sass config.assets.compile = false config.assets.digest = true @@ -101,6 +134,5 @@ config.action_view.cache_template_loading = true end - # Uncomment if you wish to allow Action Cable access from any origin. - # config.action_cable.disable_request_forgery_protection = true + config.hosts << "rubygems.test" end diff --git a/config/environments/production.rb b/config/environments/production.rb index c875f349664..fce4d18fc9b 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -1,12 +1,10 @@ -require Rails.root.join("config", "secret") if Rails.root.join("config", "secret.rb").file? -require_relative "../../lib/middleware/redirector" 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.cache_classes = true + config.enable_reloading = false # Eager load code on boot. This eager loads most of Rails and # your application in memory, allowing both threaded web servers @@ -18,60 +16,71 @@ config.consider_all_requests_local = false config.action_controller.perform_caching = true - # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] - # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). + # Ensures that a master key has been made available in ENV["RAILS_MASTER_KEY"], config/master.key, or an environment + # key such as config/credentials/production.key. This key is used to decrypt credentials (and other encrypted files). # config.require_master_key = true - # Disable serving static files from the `/public` folder by default since - # Apache or NGINX already handles this. - config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? + # Disable serving static files from `public/`, relying on NGINX/Apache to do so instead. + config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present? config.public_file_server.headers = { 'Cache-Control' => 'max-age=315360000, public', 'Expires' => 'Thu, 31 Dec 2037 23:55:55 GMT' } # Compress JavaScript using a preprocessor - config.assets.js_compressor = :uglifier + config.assets.js_compressor = :terser # Compress CSS using a preprocessor. config.assets.css_compressor = :sass - # Do not fallback to assets pipeline if a precompiled asset is missed. + # Do not fall back to assets pipeline if a precompiled asset is missed. config.assets.compile = false # Enable serving of images, stylesheets, and JavaScripts from an asset server. - # config.asset_host = 'http://assets.example.com' + # config.asset_host = "http://assets.example.com" # Specifies the header that your server uses for sending files. - # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache - # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX + # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache + # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX + + # Assume all access to the app is happening through a SSL-terminating reverse proxy. + # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies. + # config.assume_ssl = true # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. config.force_ssl = true config.ssl_options = { hsts: { expires: 365.days, subdomains: false }, redirect: { - exclude: lambda do |request| - insecure_dependency_api = (request.host == "insecure.rubygems.org" && request.path =~ %r{^/(info|versions|api/v1/dependencies)}) - request.path.start_with?('/internal') or insecure_dependency_api - end + exclude: ->(request) { request.path.start_with?('/internal') } } } # Include generic and useful information about system operation, but avoid logging too much # information to avoid inadvertent exposure of personally identifiable information (PII). - config.log_level = :info + $stdout.sync = true + config.rails_semantic_logger.format = :json + config.rails_semantic_logger.semantic = true + config.rails_semantic_logger.add_file_appender = false + config.semantic_logger.add_appender(io: $stdout, formatter: config.rails_semantic_logger.format) # Prepend all log lines with the following tags. # config.log_tags = [ :request_id ] + # "info" includes generic and useful information about system operation, but avoids logging too much + # information to avoid inadvertent exposure of personally identifiable information (PII). If you + # want to log everything, set the level to "debug". + config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") + # Use a different cache store in production. # config.cache_store = :mem_cache_store # Use a real queuing backend for Active Job (and separate queues per environment). - # config.active_job.queue_adapter = :resque + # config.active_job.queue_adapter = :resque # config.active_job.queue_name_prefix = "gemcutter_production" + # Disable caching for Action Mailer templates even if Action Controller + # caching is enabled. config.action_mailer.perform_caching = false # Ignore bad email addresses and do not raise email delivery errors. @@ -96,53 +105,26 @@ # Tell Active Support which deprecation messages to disallow. config.active_support.disallowed_deprecation_warnings = [] - # Use default logging formatter so that PID and timestamp are not suppressed. - # config.log_formatter = ::Logger::Formatter.new - - # Use a different logger for distributed setups. - # require "syslog/logger" - # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') - - # if ENV["RAILS_LOG_TO_STDOUT"].present? - # logger = ActiveSupport::Logger.new(STDOUT) - # logger.formatter = config.log_formatter - # config.logger = ActiveSupport::TaggedLogging.new(logger) - # end - - # Custom logging config. - config.logger = ActiveSupport::Logger.new($stdout) - # Do not dump schema after migrations. config.active_record.dump_schema_after_migration = false + # 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" } } + config.cache_store = :mem_cache_store, ENV['MEMCACHED_ENDPOINT'], { failover: true, socket_timeout: 1.5, socket_failure_delay: 0.2, compress: true, - compression_min_size: 524_288 + compression_min_size: 524_288, + value_max_bytes: 2_097_152 # 2MB } - config.middleware.use Redirector - - # Inserts middleware to perform automatic connection switching. - # The `database_selector` hash is used to pass options to the DatabaseSelector - # middleware. The `delay` is used to determine how long to wait after a write - # to send a subsequent read to the primary. - # - # The `database_resolver` class is used by the middleware to determine which - # database is appropriate to use based on the time delay. - # - # The `database_resolver_context` class is used by the middleware to set - # timestamps for the last write to the primary. The resolver uses the context - # class timestamps to determine how long to wait before reading from the - # replica. - # - # By default Rails will store a last write timestamp in the session. The - # DatabaseSelector middleware is designed as such you can define your own - # strategy for connection switching and pass that into the middleware through - # these configuration options. - # config.active_record.database_selector = { delay: 2.seconds } - # config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver - # config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session + require_relative "../../lib/gemcutter/middleware/redirector" + config.middleware.use Gemcutter::Middleware::Redirector end diff --git a/config/environments/staging.rb b/config/environments/staging.rb index 37f818fc592..f2a52cdfe87 100644 --- a/config/environments/staging.rb +++ b/config/environments/staging.rb @@ -1,11 +1,11 @@ require Rails.root.join("config", "secret") if Rails.root.join("config", "secret.rb").file? -require_relative "../../lib/middleware/redirector" +require_relative "../../lib/gemcutter/middleware/redirector" Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb. # Code is not reloaded between requests. - config.cache_classes = true + config.enable_reloading = false # Eager load code on boot. This eager loads most of Rails and # your application in memory, allowing both threaded web servers @@ -31,7 +31,7 @@ } # Compress JavaScripts and CSS. - config.assets.js_compressor = :uglifier + config.assets.js_compressor = :terser config.assets.css_compressor = :sass # Do not fallback to assets pipeline if a precompiled asset is missed. @@ -56,16 +56,17 @@ config.ssl_options = { hsts: { expires: 365.days, subdomains: false }, redirect: { - exclude: lambda do |request| - insecure_dependency_api = (request.host == "insecure.rubygems.org" && request.path =~ %r{^/(info|versions|api/v1/dependencies)}) - request.path.start_with?('/internal') or insecure_dependency_api - end + exclude: ->(request) { request.path.start_with?('/internal') } } } # Use the lowest log level to ensure availability of diagnostic information # when problems arise. - config.log_level = :info + config.log_level = ENV['RAILS_LOG_LEVEL'].present? ? ENV['RAILS_LOG_LEVEL'].to_sym : :info + config.rails_semantic_logger.format = :json + config.rails_semantic_logger.semantic = true + config.rails_semantic_logger.add_file_appender = false + SemanticLogger.add_appender(io: $stdout, formatter: :json) # Prepend all log lines with the following tags. # config.log_tags = [ :request_id ] @@ -93,22 +94,6 @@ # Send deprecation notices to registered listeners. config.active_support.deprecation = :notify - # Use default logging formatter so that PID and timestamp are not suppressed. - # config.log_formatter = ::Logger::Formatter.new - - # Use a different logger for distributed setups. - # require 'syslog/logger' - # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') - - # if ENV["RAILS_LOG_TO_STDOUT"].present? - # logger = ActiveSupport::Logger.new($stdout) - # logger.formatter = config.log_formatter - # config.logger = ActiveSupport::TaggedLogging.new(logger) - # end - - # Custom logging config. - config.logger = ActiveSupport::Logger.new($stdout) - # Do not dump schema after migrations. config.active_record.dump_schema_after_migration = false @@ -117,8 +102,9 @@ socket_timeout: 1.5, socket_failure_delay: 0.2, compress: true, - compression_min_size: 524_288 + compression_min_size: 524_288, + value_max_bytes: 2_097_152 # 2MB } - config.middleware.use Redirector + config.middleware.use Gemcutter::Middleware::Redirector end diff --git a/config/environments/test.rb b/config/environments/test.rb index f526bf125cc..a65ac8280f1 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -1,4 +1,3 @@ -require_relative "../../lib/middleware/redirector" require "active_support/core_ext/integer/time" # The test environment is used exclusively to run your application's @@ -9,42 +8,51 @@ Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb. - config.cache_classes = true + # While tests run files are not watched, reloading is not necessary. + config.enable_reloading = false - # Do not eager load code on boot. This avoids loading your whole application - # just for the purpose of running a single test. If you are using a tool that - # preloads Rails for running tests, you may have to set it to true. - config.eager_load = 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.enabled = true config.public_file_server.headers = { - 'Cache-Control' => "public, max-age=#{1.hour.to_i}" + "Cache-Control" => "public, max-age=#{1.hour.to_i}" } # Show full error reports and disable caching. config.consider_all_requests_local = true config.action_controller.perform_caching = false + config.cache_store = :mem_cache_store # Raise exceptions instead of rendering exception templates. - config.action_dispatch.show_exceptions = false + config.action_dispatch.show_exceptions = :none # Disable request forgery protection in test environment. config.action_controller.allow_forgery_protection = false + # Disable caching for Action Mailer templates even if Action Controller + # caching is enabled. config.action_mailer.perform_caching = false # 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 + + # Unlike controllers, the mailer instance doesn't have any context about the + # incoming request so you'll need to provide the :host parameter yourself. config.action_mailer.default_url_options = { host: Gemcutter::HOST, + port: "31337", protocol: Gemcutter::PROTOCOL } # Print deprecation notices to the stderr. config.active_support.deprecation = :stderr - require 'clearance_backdoor' + require_relative "../../lib/clearance_backdoor" config.middleware.use ClearanceBackdoor # Raise exceptions for disallowed deprecations. @@ -54,11 +62,20 @@ # Tell Active Support which deprecation messages to disallow. config.active_support.disallowed_deprecation_warnings = [] - config.cache_store = :mem_cache_store # Raises error for missing translations. - # config.i18n.raise_on_missing_translations = true + config.i18n.raise_on_missing_translations = true config.active_job.queue_adapter = :test # 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 + + BCrypt::Engine.cost = BCrypt::Engine::MIN_COST + + if ENV["CI"] && !ENV["RAILS_ENABLE_TEST_LOG"] + config.logger = Logger.new(nil) + config.log_level = :fatal + end end diff --git a/config/importmap.rb b/config/importmap.rb new file mode 100644 index 00000000000..4897472eefb --- /dev/null +++ b/config/importmap.rb @@ -0,0 +1,23 @@ +# Pin npm packages by running ./bin/importmap + +pin "jquery" # @3.7.1 +pin "@rails/ujs", to: "@rails--ujs.js" # @7.1.3 +pin "application" +pin_all_from "app/javascript/src", under: "src" + +# stimulus.min.js is a compiled asset from stimulus-rails gem +pin "@hotwired/stimulus", to: "stimulus.min.js" +# stimulus-loading.js is a compiled asset only available from stimulus-rails gem +pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" +pin_all_from "app/javascript/controllers", under: "controllers" +pin "@stimulus-components/clipboard", to: "@stimulus-components--clipboard.js" # @5.0.0 + +# vendored and adapted from https://github.com/mdo/github-buttons/blob/master/src/js.js +pin "github-buttons" +# vendored from github in the before times, not compatible with newest version without changes +pin "webauthn-json" + +# Avo custom JS entrypoint +pin "avo.custom", preload: false +pin "stimulus-rails-nested-form", preload: false # @4.1.0 +pin "local-time" # @3.0.2 diff --git a/config/initializers/active_model_types.rb b/config/initializers/active_model_types.rb new file mode 100644 index 00000000000..6f1a5ab64c3 --- /dev/null +++ b/config/initializers/active_model_types.rb @@ -0,0 +1,4 @@ +ActiveSupport.on_load(:active_model) do + ActiveModel::Type.register(:global_id, Types::GlobalId) + ActiveModel::Type.register(:duration, Types::Duration) +end diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb index 9bce8d198b6..4658f8394f0 100644 --- a/config/initializers/assets.rb +++ b/config/initializers/assets.rb @@ -1,7 +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' +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/avo.rb b/config/initializers/avo.rb new file mode 100644 index 00000000000..17c1acae253 --- /dev/null +++ b/config/initializers/avo.rb @@ -0,0 +1,165 @@ +# For more information regarding these settings check out our docs https://docs.avohq.io +Avo.configure do |config| + ## == Routing == + config.root_path = '/admin' + + # Where should the user be redirected when visting the `/avo` url + config.home_path = "/admin/dashboards/dashy" + + ## == Licensing == + config.license = 'pro' # change this to 'pro' when you add the license key + config.license_key = ENV['AVO_LICENSE_KEY'] + + ## == Set the context == + config.set_context do + # Return a context object that gets evaluated in Avo::ApplicationController + end + + ## == Authentication == + config.current_user_method = :admin_user + config.authenticate_with do + redirect_to '/' unless _current_user&.valid? + Current.user = begin + User.security_user + rescue ActiveRecord::RecordNotFound + nil + end + Current.request = request + end + config.sign_out_path_name = :admin_logout_path + + ## == Authorization == + config.authorization_methods = { + index: 'avo_index?', + show: 'avo_show?', + edit: 'avo_edit?', + new: 'avo_new?', + update: 'avo_update?', + create: 'avo_create?', + destroy: 'avo_destroy?', + search: 'avo_search?' + } + config.raise_error_on_missing_policy = true + config.authorization_client = "Admin::AuthorizationClient" + + ## == Localization == + # config.locale = 'en-US' + + ## == Resource options == + config.resource_controls_placement = :left + # config.model_resource_mapping = {} + # config.default_view_type = :table + # config.per_page = 24 + # config.per_page_steps = [12, 24, 48, 72] + # config.via_per_page = 8 + # config.id_links_to_resource = false + # config.cache_resources_on_index_view = true + ## permanent enable or disable cache_resource_filters, default value is false + # config.cache_resource_filters = false + ## provide a lambda to enable or disable cache_resource_filters per user/resource. + # config.cache_resource_filters = ->(current_user:, resource:) { current_user.cache_resource_filters?} + + ## == Customization == + config.app_name = "RubyGems.org (#{Rails.env})" + # config.timezone = 'UTC' + # config.currency = 'USD' + # config.hide_layout_when_printing = false + # config.full_width_container = false + # config.full_width_index_view = false + # config.search_debounce = 300 + # config.view_component_path = "app/components" + # config.display_license_request_timeout_error = true + # config.disabled_features = [] + # config.resource_controls = :right + # config.tabs_style = :tabs # can be :tabs or :pills + # config.buttons_on_form_footers = true + # config.field_wrapper_layout = true + + ## == Branding == + # config.branding = { + # colors: { + # background: "248 246 242", + # 100 => "#CEE7F8", + # 400 => "#399EE5", + # 500 => "#0886DE", + # 600 => "#066BB2", + # }, + # chart_colors: ["#0B8AE2", "#34C683", "#2AB1EE", "#34C6A8"], + # logo: "/avo-assets/logo.png", + # logomark: "/avo-assets/logomark.png", + # placeholder: "/avo-assets/placeholder.svg", + # favicon: "/avo-assets/favicon.ico" + # } + + ## == Breadcrumbs == + # config.display_breadcrumbs = true + # config.set_initial_breadcrumbs do + # add_breadcrumb "Home", '/avo' + # end + + ## == Menus == + config.main_menu = lambda { + section "Dashboards", icon: "dashboards" do + all_dashboards + end + + section "Resources", icon: "resources" do + Avo::App.resources_for_navigation.group_by { |r| r.model_class.module_parent_name }.sort_by { |k, _| k.to_s }.each do |namespace, reses| + if namespace.present? + group namespace.titleize, icon: "folder" do + reses.each do |res| + resource res.route_key + end + end + else + reses.each do |res| + resource res.route_key + end + end + end + end + + unless all_tools.empty? + section "Tools", icon: "tools" do + all_tools + end + end + } + + config.profile_menu = lambda { + link_to "Admin Profile", + path: avo.resources_admin_github_user_path(current_user), + icon: "user-circle" + } +end + +Rails.configuration.to_prepare do + Avo::ApplicationController.include GitHubOAuthable + Avo::BaseController.prepend AvoAuditable + Avo::BaseResource.include Concerns::AvoAuditableResource + + Avo::ApplicationController.content_security_policy do |policy| + policy.style_src :self, "https://fonts.googleapis.com", :unsafe_inline + end + + # Fix for https://github.com/rails/rails/issues/49783 + Avo::Views::ResourceEditComponent.class_eval do + def field_name(object_name, *, **) + object_name = object_name.to_s.gsub(/\[(\w+)\[(\w+)\]\]/, '[\1][\2]') + super + end + end + + Avo::Fields::IndexComponent.prepend(Module.new do + def initialize(flush: false, **) + super(**) + @flush = flush + end + + def field_wrapper_args + args = super + args[:flush] = @flush + args + end + end) +end diff --git a/config/initializers/better_html.rb b/config/initializers/better_html.rb new file mode 100644 index 00000000000..3eb5dc80cfe --- /dev/null +++ b/config/initializers/better_html.rb @@ -0,0 +1,5 @@ +BetterHtml.configure do |config| + config.template_exclusion_filter = proc { |filename| + filename.include?("avo") || filename.include?("/railties-") + } +end diff --git a/config/initializers/clearance.rb b/config/initializers/clearance.rb index 5c7f7b57e89..d329d56f753 100644 --- a/config/initializers/clearance.rb +++ b/config/initializers/clearance.rb @@ -1,12 +1,15 @@ +require_relative "../../lib/confirmed_user_guard" + Clearance.configure do |config| - config.allow_sign_up = (ENV['DISABLE_SIGNUP'].to_s == 'true') ? false : true - config.mailer_sender = "RubyGems.org " - config.secure_cookie = true unless Rails.env.test? || Rails.env.development? + config.allow_sign_up = ENV['DISABLE_SIGNUP'].to_s != 'true' + config.mailer_sender = Gemcutter::MAIL_SENDER + config.secure_cookie = true unless Rails.env.local? config.password_strategy = Clearance::PasswordStrategies::BCrypt config.sign_in_guards = [ConfirmedUserGuard] config.rotate_csrf_on_sign_in = true config.cookie_expiration = ->(_cookies) { 2.weeks.from_now.utc } config.routes = false + config.signed_cookie = :migrate end class Clearance::Session diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index 702e60c3db2..616790254b5 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -1,31 +1,58 @@ # Be sure to restart your server when you modify this file. -# Define an application-wide content security policy -# For further information see the following documentation -# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy +# Define an application-wide content security policy. +# See the Securing Rails Applications Guide for more information: +# https://guides.rubyonrails.org/security.html#content-security-policy-header -Rails.application.config.content_security_policy do |policy| - unless Rails.env.development? +Rails.application.configure do + config.content_security_policy do |policy| policy.default_src :self policy.font_src :self, "https://fonts.gstatic.com" - policy.img_src :self, "https://secure.gaug.es", "https://gravatar.com", "https://secure.gravatar.com", "https://*.fastly-insights.com" + policy.img_src :self, "https://secure.gaug.es", "https://gravatar.com", "https://www.gravatar.com", "https://secure.gravatar.com", + "https://*.fastly-insights.com", "https://avatars.githubusercontent.com" policy.object_src :none - policy.script_src :self, "https://secure.gaug.es", "https://www.fastly-insights.com" - policy.style_src :self, "https://fonts.googleapis.com" - policy.connect_src :self, "https://s3-us-west-2.amazonaws.com/rubygems-dumps/", "https://*.fastly-insights.com", "https://fastly-insights.com", "https://api.github.com" - end + # NOTE: This scirpt_src is overridden for all requests in ApplicationController + # This is the baseline in case the override is ever skipped + policy.script_src :self, "https://secure.gaug.es", "https://www.fastly-insights.com" + policy.style_src :self, "https://fonts.googleapis.com" + policy.connect_src :self, "https://s3-us-west-2.amazonaws.com/rubygems-dumps/", "https://*.fastly-insights.com", "https://fastly-insights.com", + "https://api.github.com", "http://localhost:*" + policy.form_action :self, "https://github.com/login/oauth/authorize" + policy.frame_ancestors :self + policy.base_uri :self - # Specify URI for violation reports - # policy.report_uri "/csp-violation-report-endpoint" + # Specify URI for violation reports + policy.report_uri lambda { + dd_api_key = ENV['DATADOG_CSP_API_KEY'].presence + url = ActionDispatch::Http::URL.url_for( + protocol: 'https', + host: 'csp-report.browser-intake-datadoghq.com', + path: '/api/v2/logs', + params: { + "dd-api-key": dd_api_key, + "dd-evp-origin": 'content-security-policy', + ddsource: 'csp-report', + ddtags: { + service: "rubygems.org", + version: AppRevision.version, + env: Rails.env, + trace_id: Datadog::Tracing.correlation&.trace_id, + "gemcutter.user.id": (current_user.id if respond_to?(:signed_in?) && signed_in?) + }.compact.map { |k, v| "#{k}:#{v}" }.join(',') + } + ) + # ensure we compute the URL on development/test, + # but onlu return it if the API key is configures + url if dd_api_key + } + end end -# If you are using UJS then enable automatic nonce generation -# Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) } - -# Set the nonce only to specific directives -# Rails.application.config.content_security_policy_nonce_directives = %w(script-src) - -# Report CSP violations to a specified URI -# For further information see the following documentation: -# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only -# Rails.application.config.content_security_policy_report_only = true +# Generate session nonces for permitted importmap, inline scripts, and inline styles. +Rails.application.config.content_security_policy_nonce_generator = lambda { |request| + # Suggested nonce generator doesn't work on first page load https://github.com/rails/rails/issues/48463 + # Related PR attempting to fix: https://github.com/rails/rails/pull/48510 + request.session.send(:load_for_write!) # force session to be created + request.session.id.to_s.presence || SecureRandom.base64(16) +} +Rails.application.config.content_security_policy_nonce_directives = %w[script-src style-src] diff --git a/config/initializers/cookie_rotator.rb b/config/initializers/cookie_rotator.rb new file mode 100644 index 00000000000..f7655147ca3 --- /dev/null +++ b/config/initializers/cookie_rotator.rb @@ -0,0 +1,14 @@ +Rails.application.config.after_initialize do + Rails.application.config.action_dispatch.cookies_rotations.tap do |cookies| + salt = Rails.application.config.action_dispatch.authenticated_encrypted_cookie_salt + secret_key_base = Rails.application.secret_key_base + + key_generator = ActiveSupport::KeyGenerator.new( + secret_key_base, iterations: 1000, hash_digest_class: OpenSSL::Digest::SHA1 + ) + key_len = ActiveSupport::MessageEncryptor.key_len + secret = key_generator.generate_key(salt, key_len) + + cookies.rotate :encrypted, secret + end +end diff --git a/config/initializers/datadog.rb b/config/initializers/datadog.rb new file mode 100644 index 00000000000..9681e9c18aa --- /dev/null +++ b/config/initializers/datadog.rb @@ -0,0 +1,56 @@ +require "app_revision" + +return if Rails.env.local? # Don't enable Datadog in local Development & Test environments + +Datadog.configure do |c| + # unified service tagging + + c.service = "rubygems.org" + c.version = AppRevision.version + c.env = Rails.env + + # Enabling datadog functionality + + enabled = ENV["DD_AGENT_HOST"].present? && !defined?(Rails::Console) + c.runtime_metrics.enabled = enabled + c.profiling.enabled = enabled + c.tracing.enabled = enabled + c.tracing.log_injection = enabled + c.telemetry.enabled = enabled + c.remote.enabled = enabled + + unless enabled + c.tracing.log_injection = false + c.tracing.test_mode.enabled = true # Set transport to no-op mode. Does not retain traces. + c.diagnostics.startup_logs.enabled = false + end + + c.tags = { + "git.commit.sha" => AppRevision.version, + "git.repository_url" => "https://github.com/rubygems/rubygems.org" + } + + # Configuring the datadog library + + c.logger.instance = SemanticLogger[Datadog] + + # Configuring tracing + + c.tracing.report_hostname = true + + c.tracing.instrument :aws + c.tracing.instrument :dalli + c.tracing.instrument :faraday, split_by_domain: true, service_name: c.service + c.tracing.instrument :http, split_by_domain: true, service_name: c.service + c.tracing.instrument :opensearch, service_name: c.service + c.tracing.instrument :pg + c.tracing.instrument :rails, request_queuing: true + c.tracing.instrument :shoryuken if defined?(Shoryuken) +end + +Datadog::Tracing.before_flush( + # Remove spans for the /internal/ping endpoint + Datadog::Tracing::Pipeline::SpanFilter.new { |span| span.resource == "Internal::PingController#index" } +) + +require "datadog/auto_instrument" diff --git a/config/initializers/delayed_job.rb b/config/initializers/delayed_job.rb deleted file mode 100644 index 9a2efdfed02..00000000000 --- a/config/initializers/delayed_job.rb +++ /dev/null @@ -1,7 +0,0 @@ -require 'delayed_job' -Delayed::Worker.max_attempts = 10 -Delayed::Worker.max_run_time = 5.minutes -Delayed::Worker.destroy_failed_jobs = false -Delayed::Worker.logger = Logger.new(Rails.root.join('log', 'delayed_job.log')) - -PRIORITIES = { push: 1, download: 2, web_hook: 3, profile_deletion: 3, stats: 4 }.freeze diff --git a/config/initializers/elasticsearch.rb b/config/initializers/elasticsearch.rb index 0201901f732..68ebad21d4e 100644 --- a/config/initializers/elasticsearch.rb +++ b/config/initializers/elasticsearch.rb @@ -1,7 +1,8 @@ require 'faraday_middleware/aws_sigv4' +require 'opensearch-dsl' port = 9200 -if (Rails.env.test? || Rails.env.development?) && Toxiproxy.running? +if Rails.env.local? && Toxiproxy.running? port = 22_221 Toxiproxy.populate( [ @@ -14,10 +15,14 @@ ) end -url = ENV['ELASTICSEARCH_URL'] || "http://localhost:#{port}" +options = {} -Elasticsearch::Model.client = Elasticsearch::Client.new(url: url) do |f| - if Rails.env.staging? || Rails.env.production? +options[:url] = ENV['ELASTICSEARCH_URL'] || "http://localhost:#{port}" + +options[:tracer] = SemanticLogger[OpenSearch::Client] + +Searchkick.client = OpenSearch::Client.new(**options.compact) do |f| + unless Rails.env.local? f.request :aws_sigv4, service: 'es', region: ENV['AWS_REGION'], @@ -25,9 +30,3 @@ secret_access_key: ENV['AWS_SECRET_ACCESS_KEY'] end end - -if Rails.env.development? - tracer = ActiveSupport::Logger.new('log/elasticsearch.log') - tracer.level = Logger::DEBUG - Elasticsearch::Model.client.transport.tracer = tracer -end diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb index 1f50c6388f8..89c5d312f58 100644 --- a/config/initializers/filter_parameter_logging.rb +++ b/config/initializers/filter_parameter_logging.rb @@ -1,6 +1,9 @@ # Be sure to restart your server when you modify this file. -# Configure sensitive parameters which will be filtered from the log file. +# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. +# Use this to limit dissemination of sensitive information. +# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. Rails.application.config.filter_parameters += %i[ - password passw secret token _key crypt salt certificate otp ssn api_key + passw email secret token _key crypt salt certificate otp ssn + api_key recovery_codes seed jwt password ] diff --git a/config/initializers/forbidden_yaml.rb b/config/initializers/forbidden_yaml.rb deleted file mode 100644 index da8eb0b6bcb..00000000000 --- a/config/initializers/forbidden_yaml.rb +++ /dev/null @@ -1,56 +0,0 @@ -# XXX: This is purely a monkey patch to close the exploit vector for now, a more -# permanent solution should be pushed upstream into rubygems. - -require "rubygems" -require "rubygems/package" - -# Assert we're using Psych -abort "Use Psych for YAML, install libyaml and reinstall ruby" unless YAML == Psych - -module Gem - class Specification - WHITELISTED_CLASSES = %w( - Symbol - Time - Date - Gem::Dependency - Gem::Platform - Gem::Requirement - Gem::Specification - Gem::Version - Gem::Version::Requirement - ) - - WHITELISTED_SYMBOLS = %w( - development - runtime - ) - - def self.from_yaml(input) - input = normalize_yaml_input input - spec = Psych.safe_load(input, WHITELISTED_CLASSES, WHITELISTED_SYMBOLS, true) - - fail Gem::EndOfYAMLException if spec && spec.class == FalseClass - - unless Gem::Specification === spec - fail Gem::Exception, "YAML data doesn't evaluate to gem specification" - end - - spec.specification_version ||= NONEXISTENT_SPECIFICATION_VERSION - spec.reset_nil_attributes_to_default - - spec - end - end - class Package - def read_checksums gem - Gem.load_yaml - - @checksums = gem.seek 'checksums.yaml.gz' do |entry| - Zlib::GzipReader.wrap entry do |gz_io| - Psych.safe_load(gz_io.read, Gem::Specification::WHITELISTED_CLASSES, Gem::Specification::WHITELISTED_SYMBOLS, true) - end - end - end - end -end diff --git a/config/initializers/gem_safe_yaml.rb b/config/initializers/gem_safe_yaml.rb new file mode 100644 index 00000000000..5a4fbbfc06d --- /dev/null +++ b/config/initializers/gem_safe_yaml.rb @@ -0,0 +1,10 @@ +require 'rubygems/package' + +Gem.load_yaml +raise "Update rubygems to 3.5.7 or greater for Gem::SafeYAML.aliases_enabled= support" unless Gem::SafeYAML.respond_to?(:aliases_enabled=) +Gem::SafeYAML.aliases_enabled = false + +Gem::Package.class_eval do + include SemanticLogger::Loggable + delegate :warn, to: :logger +end diff --git a/config/initializers/good_job.rb b/config/initializers/good_job.rb new file mode 100644 index 00000000000..4c3af798491 --- /dev/null +++ b/config/initializers/good_job.rb @@ -0,0 +1,51 @@ +Rails.application.configure do + config.good_job.preserve_job_records = true + config.good_job.retry_on_unhandled_error = false + config.good_job.on_thread_error = ->(exception) { Rails.error.report(exception, handled: false) } + config.good_job.queues = '*' + config.good_job.shutdown_timeout = 25 # seconds + config.good_job.logger = SemanticLogger[GoodJob] + + config.good_job.enable_cron = !Rails.env.development? + config.good_job.cron = { + good_job_statsd: { + cron: "every 15s", + class: "GoodJobStatsDJob", + set: { priority: 10 }, + description: "Sending GoodJob metrics to statsd every 15s" + }, + mfa_usage_stats: { + cron: "every hour", + class: "MfaUsageStatsJob", + set: { priority: 10 }, + description: "Sending MFA usage metrics to statsd every hour" + }, + refresh_oidc_providers: { + cron: "every 30m", + class: "RefreshOIDCProvidersJob", + set: { priority: 10 }, + description: "Refreshing all OIDC provider configurations every 30m" + } + } + + # see https://github.com/bensheldon/good_job/pull/883 + # this makes good_job consistent with the priorities we used + # previously for delayed job + config.good_job.smaller_number_is_higher_priority = true + + GoodJob.active_record_parent_class = "ApplicationRecord" + + if Rails.env.development? && GoodJob::CLI.within_exe? + GoodJob::CLI.log_to_stdout = false + + console = ActiveSupport::Logger.new($stdout) + console.formatter = Rails.logger.formatter + console.level = Rails.logger.level + + Rails.logger.extend(ActiveSupport::Logger.broadcast(console)) unless ActiveSupport::Logger.logger_outputs_to?(Rails.logger, $stderr, $stdout) + + ActiveRecord::Base.logger = nil + GoodJob.logger = Rails.logger + StatsD.singleton_client = StatsD::Instrument::Client.new + end +end diff --git a/config/initializers/high_voltage.rb b/config/initializers/high_voltage.rb deleted file mode 100644 index 91ee06f5c70..00000000000 --- a/config/initializers/high_voltage.rb +++ /dev/null @@ -1,3 +0,0 @@ -HighVoltage.configure do |config| - config.routes = false -end diff --git a/config/initializers/honeybadger.rb b/config/initializers/honeybadger.rb index 2a90762e3ff..cf96d851c43 100644 --- a/config/initializers/honeybadger.rb +++ b/config/initializers/honeybadger.rb @@ -1,6 +1,13 @@ -Honeybadger.configure do |config| - config.report_data = false if Rails.env.development? - config.before_notify do |notice| - notice.halt! if ActionDispatch::ExceptionWrapper.rescue_responses.key?(notice.error_class) +return if Rails.env.local? # Don't enable Honeybadger in local Development & Test environments + +Rails.logger.silence(:error) do + require "honeybadger" + + Honeybadger.configure do |config| + config.before_notify do |notice| + notice.halt! if ActionDispatch::ExceptionWrapper.rescue_responses.key?(notice.error_class) + end + + config.logger = SemanticLogger[Honeybadger] end end diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index ac033bf9dc8..521a346028e 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -4,13 +4,21 @@ # are locale specific, and you may define rules for as many different # locales as you wish. All of these examples are active by default: # ActiveSupport::Inflector.inflections(:en) do |inflect| -# inflect.plural /^(ox)$/i, '\1en' -# inflect.singular /^(ox)en/i, '\1' -# inflect.irregular 'person', 'people' +# inflect.plural /^(ox)$/i, "\\1en" +# inflect.singular /^(ox)en/i, "\\1" +# inflect.irregular "person", "people" # inflect.uncountable %w( fish sheep ) # end # These inflection rules are supported but not enabled by default: # ActiveSupport::Inflector.inflections(:en) do |inflect| -# inflect.acronym 'RESTful' +# inflect.acronym "RESTful" # end + +ActiveSupport::Inflector.inflections(:en) do |inflect| + inflect.acronym "OAuth" + inflect.acronym "OAuthable" + inflect.acronym "GitHub" + inflect.acronym "StatsD" + inflect.acronym "OIDC" +end diff --git a/config/initializers/launchdarkly.rb b/config/initializers/launchdarkly.rb new file mode 100644 index 00000000000..d1a5de98885 --- /dev/null +++ b/config/initializers/launchdarkly.rb @@ -0,0 +1,12 @@ +Rails.application.configure do + launch_darkly_sdk_key = ENV["LAUNCH_DARKLY_SDK_KEY"] + ld_config = LaunchDarkly::Config.new( + logger: SemanticLogger[LaunchDarkly], + offline: launch_darkly_sdk_key.blank? + ) + + config.launch_darkly_client = LaunchDarkly::LDClient.new( + launch_darkly_sdk_key.to_s, + ld_config + ) +end diff --git a/config/initializers/letter_opener.rb b/config/initializers/letter_opener.rb new file mode 100644 index 00000000000..bf30067794e --- /dev/null +++ b/config/initializers/letter_opener.rb @@ -0,0 +1,5 @@ +return unless Rails.env.development? + +Rails.configuration.to_prepare do + LetterOpenerWeb::ApplicationController.content_security_policy false +end diff --git a/config/initializers/lograge.rb b/config/initializers/lograge.rb deleted file mode 100644 index b96464d17f1..00000000000 --- a/config/initializers/lograge.rb +++ /dev/null @@ -1,20 +0,0 @@ -if Rails.env.production? || Rails.env.staging? - Rails.application.configure do - # Enable lograge - config.lograge.enabled = true - - # Use (Datadog flavored) JSON - config.lograge.formatter = Lograge::Formatters::Datadog.new - - # Add custom fields - config.lograge.custom_payload do |controller| - { - url: controller.request.url, - params: controller.request.filtered_parameters.except('controller', 'action', 'format', 'utf8'), - client_ip: controller.request.ip, - user_agent: controller.request.user_agent, - request_id: controller.request.uuid - } - end - end -end diff --git a/config/initializers/lookbook.rb b/config/initializers/lookbook.rb new file mode 100644 index 00000000000..47f5fba85cf --- /dev/null +++ b/config/initializers/lookbook.rb @@ -0,0 +1 @@ +Rails.application.config.lookbook.preview_layout = "component_preview" if Rails.env.local? diff --git a/config/initializers/magic.rb b/config/initializers/magic.rb new file mode 100644 index 00000000000..e569699a47e --- /dev/null +++ b/config/initializers/magic.rb @@ -0,0 +1,19 @@ +if ENV["MAGIC"].nil? + # Be resilient to the gem being moved after installation, as is done in the docker image + glob = File.join( + Gem.loaded_specs.fetch("ruby-magic").full_gem_path, + "ports", + "*", + "libmagic", + Magic.version_string, + "share", + "misc", + "magic.mgc" + ) + ENV["MAGIC"] = + begin + Dir.glob(glob).sole + rescue Enumerable::SoleItemExpectedError + raise "Could not find magic.mgc in #{glob.inspect}\nTry running `bundle pristine ruby-magic` to fix this issue." + end +end diff --git a/config/initializers/maintenance_tasks.rb b/config/initializers/maintenance_tasks.rb new file mode 100644 index 00000000000..3bdfe469a31 --- /dev/null +++ b/config/initializers/maintenance_tasks.rb @@ -0,0 +1,18 @@ +MaintenanceTasks.error_handler = lambda { |error, task_context, errored_element| + errored_element = + case errored_element + when ActiveRecord::Base + errored_element.to_gid + end + + Rails.error.report( + error, + context: { task_context:, errored_element: }, + handled: false + ) +} + +Rails.autoloaders.main.on_load("MaintenanceTasks::ApplicationController") do + MaintenanceTasks::ApplicationController.include GitHubOAuthable + MaintenanceTasks::ApplicationController.prepend MaintenanceTasksAuditable +end diff --git a/config/initializers/mime_types.rb b/config/initializers/mime_types.rb index 98b245ce1f5..43ea69c3151 100644 --- a/config/initializers/mime_types.rb +++ b/config/initializers/mime_types.rb @@ -5,3 +5,4 @@ # Default Content-Type for api/v1/dependencies Mime::Type.register "application/octet-stream", :marshal +Mime::Type.register "text/plain", :sha256 diff --git a/config/initializers/net_http_purge.rb b/config/initializers/net_http_purge.rb new file mode 100644 index 00000000000..253b7f9dfcd --- /dev/null +++ b/config/initializers/net_http_purge.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Net::HTTP::Purge < Net::HTTPRequest + METHOD = "PURGE" + REQUEST_HAS_BODY = false + RESPONSE_HAS_BODY = true +end diff --git a/config/initializers/new_framework_defaults_6_0.rb b/config/initializers/new_framework_defaults_6_0.rb deleted file mode 100644 index e0376481665..00000000000 --- a/config/initializers/new_framework_defaults_6_0.rb +++ /dev/null @@ -1,37 +0,0 @@ -# Be sure to restart your server when you modify this file. -# -# This file contains migration options to ease your Rails 6.0 upgrade. -# -# Once upgraded flip defaults one by one to migrate to the new default. -# -# Read the Guide for Upgrading Ruby on Rails for more info on each option. - -# Don't force requests from old versions of IE to be UTF-8 encoded. -Rails.application.config.action_view.default_enforce_utf8 = false - -# Embed purpose and expiry metadata inside signed and encrypted -# cookies for increased security. -# -# This option is not backwards compatible with earlier Rails versions. -# It's best enabled when your entire app is migrated and stable on 6.0. -# Rails.application.config.action_dispatch.use_cookies_with_metadata = true - -# Change the return value of `ActionDispatch::Response#content_type` to Content-Type header without modification. -# Rails.application.config.action_dispatch.return_only_media_type_on_content_type = false - -# Return false instead of self when enqueuing is aborted from a callback. -Rails.application.config.active_job.return_false_on_aborted_enqueue = true - -# Use ActionMailer::MailDeliveryJob for sending parameterized and normal mail. -# -# The default delivery jobs (ActionMailer::Parameterized::DeliveryJob, ActionMailer::DeliveryJob), -# will be removed in Rails 6.1. This setting is not backwards compatible with earlier Rails versions. -# If you send mail in the background, job workers need to have a copy of -# MailDeliveryJob to ensure all delivery jobs are processed properly. -# Make sure your entire app is migrated and stable on 6.0 before using this setting. -Rails.application.config.action_mailer.delivery_job = "ActionMailer::MailDeliveryJob" - -# Enable the same cache key to be reused when the object being cached of type -# `ActiveRecord::Relation` changes by moving the volatile information (max updated at and count) -# of the relation's cache key into the cache version to support recycling cache key. -Rails.application.config.active_record.collection_cache_versioning = true diff --git a/config/initializers/new_framework_defaults_6_1.rb b/config/initializers/new_framework_defaults_6_1.rb deleted file mode 100644 index 9526b835ab6..00000000000 --- a/config/initializers/new_framework_defaults_6_1.rb +++ /dev/null @@ -1,67 +0,0 @@ -# Be sure to restart your server when you modify this file. -# -# This file contains migration options to ease your Rails 6.1 upgrade. -# -# Once upgraded flip defaults one by one to migrate to the new default. -# -# Read the Guide for Upgrading Ruby on Rails for more info on each option. - -# Support for inversing belongs_to -> has_many Active Record associations. -# Rails.application.config.active_record.has_many_inversing = true - -# Track Active Storage variants in the database. -# Rails.application.config.active_storage.track_variants = true - -# Apply random variation to the delay when retrying failed jobs. -# Rails.application.config.active_job.retry_jitter = 0.15 - -# Stop executing `after_enqueue`/`after_perform` callbacks if -# `before_enqueue`/`before_perform` respectively halts with `throw :abort`. -# Rails.application.config.active_job.skip_after_callbacks_if_terminated = true - -# Specify cookies SameSite protection level: either :none, :lax, or :strict. -# -# This change is not backwards compatible with earlier Rails versions. -# It's best enabled when your entire app is migrated and stable on 6.1. -# Rails.application.config.action_dispatch.cookies_same_site_protection = :lax - -# Generate CSRF tokens that are encoded in URL-safe Base64. -# -# This change is not backwards compatible with earlier Rails versions. -# It's best enabled when your entire app is migrated and stable on 6.1. -# Rails.application.config.action_controller.urlsafe_csrf_tokens = true - -# Specify whether `ActiveSupport::TimeZone.utc_to_local` returns a time with an -# UTC offset or a UTC time. -# ActiveSupport.utc_to_local_returns_utc_offset_times = true - -# Change the default HTTP status code to `308` when redirecting non-GET/HEAD -# requests to HTTPS in `ActionDispatch::SSL` middleware. -# Rails.application.config.action_dispatch.ssl_default_redirect_status = 308 - -# Use new connection handling API. For most applications this won't have any -# effect. For applications using multiple databases, this new API provides -# support for granular connection swapping. -# Rails.application.config.active_record.legacy_connection_handling = false - -# Make `form_with` generate non-remote forms by default. -# Rails.application.config.action_view.form_with_generates_remote_forms = false - -# Set the default queue name for the analysis job to the queue adapter default. -# Rails.application.config.active_storage.queues.analysis = nil - -# Set the default queue name for the purge job to the queue adapter default. -# Rails.application.config.active_storage.queues.purge = nil - -# Set the default queue name for the incineration job to the queue adapter default. -# Rails.application.config.action_mailbox.queues.incineration = nil - -# Set the default queue name for the routing job to the queue adapter default. -# Rails.application.config.action_mailbox.queues.routing = nil - -# Set the default queue name for the mail deliver job to the queue adapter default. -# Rails.application.config.action_mailer.deliver_later_queue_name = nil - -# Generate a `Link` header that gives a hint to modern browsers about -# preloading assets when using `javascript_include_tag` and `stylesheet_link_tag`. -# Rails.application.config.action_view.preload_links_header = true diff --git a/config/initializers/new_framework_defaults_7_2.rb b/config/initializers/new_framework_defaults_7_2.rb new file mode 100644 index 00000000000..b549c4a258a --- /dev/null +++ b/config/initializers/new_framework_defaults_7_2.rb @@ -0,0 +1,70 @@ +# Be sure to restart your server when you modify this file. +# +# This file eases your Rails 7.2 framework defaults upgrade. +# +# Uncomment each configuration one by one to switch to the new default. +# Once your application is ready to run with all new defaults, you can remove +# this file and set the `config.load_defaults` to `7.2`. +# +# Read the Guide for Upgrading Ruby on Rails for more info on each option. +# https://guides.rubyonrails.org/upgrading_ruby_on_rails.html + +### +# Controls whether Active Job's `#perform_later` and similar methods automatically defer +# the job queuing to after the current Active Record transaction is committed. +# +# Example: +# Topic.transaction do +# topic = Topic.create(...) +# NewTopicNotificationJob.perform_later(topic) +# end +# +# In this example, if the configuration is set to `:never`, the job will +# be enqueued immediately, even though the `Topic` hasn't been committed yet. +# Because of this, if the job is picked up almost immediately, or if the +# transaction doesn't succeed for some reason, the job will fail to find this +# topic in the database. +# +# If `enqueue_after_transaction_commit` is set to `:default`, the queue adapter +# will define the behaviour. +# +# Note: Active Job backends can disable this feature. This is generally done by +# backends that use the same database as Active Record as a queue, hence they +# don't need this feature. +#++ +# Rails.application.config.active_job.enqueue_after_transaction_commit = :default + +### +# Adds image/webp to the list of content types Active Storage considers as an image +# Prevents automatic conversion to a fallback PNG, and assumes clients support WebP, as they support gif, jpeg, and png. +# This is possible due to broad browser support for WebP, but older browsers and email clients may still not support +# WebP. Requires imagemagick/libvips built with WebP support. +#++ +# Rails.application.config.active_storage.web_image_content_types = %w[image/png image/jpeg image/gif image/webp] + +### +# Enable validation of migration timestamps. When set, an ActiveRecord::InvalidMigrationTimestampError +# will be raised if the timestamp prefix for a migration is more than a day ahead of the timestamp +# associated with the current time. This is done to prevent forward-dating of migration files, which can +# impact migration generation and other migration commands. +# +# Applications with existing timestamped migrations that do not adhere to the +# expected format can disable validation by setting this config to `false`. +#++ +# Rails.application.config.active_record.validate_migration_timestamps = true + +### +# Controls whether the PostgresqlAdapter should decode dates automatically with manual queries. +# +# Example: +# ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.select_value("select '2024-01-01'::date") #=> Date +# +# This query used to return a `String`. +#++ +# Rails.application.config.active_record.postgresql_adapter_decode_dates = true + +### +# Enables YJIT as of Ruby 3.3, to bring sizeable performance improvements. If you are +# deploying to a memory constrained environment you may want to set this to `false`. +#++ +# Rails.application.config.yjit = true diff --git a/config/initializers/newrelic.rb b/config/initializers/newrelic.rb deleted file mode 100644 index cf8e4cfd611..00000000000 --- a/config/initializers/newrelic.rb +++ /dev/null @@ -1,4 +0,0 @@ -# Ensure the agent is started using Unicorn -# This is needed when using Unicorn and preload_app is not set to true. -# See http://support.newrelic.com/kb/troubleshooting/unicorn-no-data -::NewRelic::Agent.after_fork(force_reconnect: true) if defined? Unicorn diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb new file mode 100644 index 00000000000..a41c0d6777e --- /dev/null +++ b/config/initializers/omniauth.rb @@ -0,0 +1,13 @@ +Rails.application.config.middleware.use OmniAuth::Builder do + configure do |config| + config.path_prefix = "/oauth" + end + + provider :github, ENV['GITHUB_KEY'], ENV['GITHUB_SECRET'], scope: %w[ + read:user + read:org + ].join(",") +end + +OmniAuth::AuthenticityTokenProtection.default_options(key: "csrf.token", authenticity_param: "_csrf") +OmniAuth.config.logger = SemanticLogger[OmniAuth] diff --git a/config/initializers/permissions_policy.rb b/config/initializers/permissions_policy.rb index 00f64d71b03..7db3b9577e6 100644 --- a/config/initializers/permissions_policy.rb +++ b/config/initializers/permissions_policy.rb @@ -1,11 +1,13 @@ +# Be sure to restart your server when you modify this file. + # Define an application-wide HTTP permissions policy. For further -# information see https://developers.google.com/web/updates/2018/06/feature-policy -# -# Rails.application.config.permissions_policy do |f| -# f.camera :none -# f.gyroscope :none -# f.microphone :none -# f.usb :none -# f.fullscreen :self -# f.payment :self, "https://secure.example.com" +# information see: https://developers.google.com/web/updates/2018/06/feature-policy + +# Rails.application.config.permissions_policy do |policy| +# policy.camera :none +# policy.gyroscope :none +# policy.microphone :none +# policy.usb :none +# policy.fullscreen :self +# policy.payment :self, "https://secure.example.com" # end diff --git a/config/initializers/prosopite.rb b/config/initializers/prosopite.rb new file mode 100644 index 00000000000..95e7e0208d6 --- /dev/null +++ b/config/initializers/prosopite.rb @@ -0,0 +1,21 @@ +if Rails.env.local? + require 'prosopite/middleware/rack' + Rails.configuration.middleware.use(Prosopite::Middleware::Rack) + + Rails.application.config.after_initialize do + Prosopite.custom_logger = SemanticLogger[Prosopite] + Prosopite.raise = true + Prosopite.ignore_queries = [] + Prosopite.allow_stack_paths = [ + # mailers need refactoring to not find based on IDs when we already have objects in memory + "app/mailers/", + + # avo auditing potentially loads things multiple times, but it will be bounded by the size of the audit + "app/avo/actions/base_action.rb", + "app/components/avo/fields/audited_changes_field/show_component.html.erb", + + # calls count for each owner, AR doesn't yet allow preloading aggregates + "app/views/ownership_requests/_ownership_request.html.erb" + ] + end +end diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index 6aa2b342a4a..ccf01536ad4 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -1,4 +1,6 @@ class Rack::Attack + include SemanticLogger::Loggable + REQUEST_LIMIT = 100 EXP_BASE_REQUEST_LIMIT = 300 PUSH_LIMIT = 400 @@ -8,6 +10,7 @@ class Rack::Attack EXP_BASE_LIMIT_PERIOD = 300.seconds EXP_BACKOFF_LEVELS = [1, 2].freeze PUSH_EXP_THROTTLE_KEY = "api/exp/push/ip".freeze + PUSH_THROTTLE_PER_USER_KEY = "api/exp/push/user".freeze ### Prevent Brute-Force Login Attacks ### @@ -24,29 +27,43 @@ class Rack::Attack # Key: "rack::attack:#{Time.now.to_i/:period}:logins/ip:#{req.ip}" protected_ui_actions = [ - { controller: "sessions", action: "create" }, - { controller: "users", action: "create" }, - { controller: "passwords", action: "edit" }, - { controller: "sessions", action: "authenticate" }, - { controller: "passwords", action: "create" }, - { controller: "profiles", action: "update" }, - { controller: "profiles", action: "destroy" }, - { controller: "email_confirmations", action: "create" } + { controller: "sessions", action: "create" }, + { controller: "users", action: "create" }, + { controller: "passwords", action: "edit" }, + { controller: "sessions", action: "authenticate" }, + { controller: "passwords", action: "create" }, + { controller: "profiles", action: "update" }, + { controller: "profiles", action: "destroy" }, + { controller: "email_confirmations", action: "create" }, + { controller: "reverse_dependencies", action: "index" } ] + otp_create_action = { controller: "sessions", action: "otp_create" } + mfa_password_edit_action = { controller: "passwords", action: "otp_edit" } + protected_ui_mfa_actions = [ - { controller: "sessions", action: "mfa_create" }, - { controller: "passwords", action: "mfa_edit" }, - { controller: "multifactor_auths", action: "create" }, - { controller: "multifactor_auths", action: "update" } + otp_create_action, + mfa_password_edit_action, + { controller: "totps", action: "create" }, + { controller: "totps", action: "destroy" }, + { controller: "multifactor_auths", action: "update" } + ] + + protected_api_key_actions = [ + { controller: "api/v1/api_keys", action: "show" }, + { controller: "api/v1/api_keys", action: "create" }, + { controller: "api/v1/api_keys", action: "update" }, + + # not technically API key, but it's the only other action that uses authenticate_or_request_with_http_basic + # and we don't want to make it easy to guess user passwords (or figure out who has mfa enabled...) + { controller: "api/v1/profiles", action: "me" } ] protected_api_mfa_actions = [ { controller: "api/v1/deletions", action: "create" }, { controller: "api/v1/owners", action: "create" }, - { controller: "api/v1/owners", action: "destroy" }, - { controller: "api/v1/api_keys", action: "show" } - ] + { controller: "api/v1/owners", action: "destroy" } + ] + protected_api_key_actions protected_ui_owners_actions = [ { controller: "owners", action: "resend_confirmation" }, @@ -54,6 +71,12 @@ class Rack::Attack { controller: "owners", action: "destroy" } ] + protected_password_actions = [ + { controller: "profiles", action: "update" }, + { controller: "profiles", action: "destroy" }, + { controller: "sessions", action: "authenticate" } + ] + def self.protected_route?(protected_actions, path, method) route_params = Rails.application.routes.recognize_path(path, method: method) protected_actions.any? { |hash| hash[:controller] == route_params[:controller] && hash[:action] == route_params[:action] } @@ -61,6 +84,21 @@ def self.protected_route?(protected_actions, path, method) false end + def self.api_hashed_key(req) + key = req.get_header("HTTP_AUTHORIZATION") || "" + return if key.blank? + hashed_key = Digest::SHA256.hexdigest(key) + ApiKey.find_by_hashed_key(hashed_key) + end + + def self.api_key_owner_id(req) + api_key = api_hashed_key(req) + return unless api_key + + URI::GID.build(app: GlobalID.app, + model_name: api_key.association(:owner).klass.name, model_id: api_key.owner_id).to_s + end + safelist("assets path") do |req| req.path.starts_with?("/assets") && req.request_method == "GET" end @@ -75,12 +113,32 @@ def self.protected_route?(protected_actions, path, method) throttle("clearance/ip/#{level}", limit: EXP_BASE_REQUEST_LIMIT * level, period: (EXP_BASE_LIMIT_PERIOD**level).seconds) do |req| req.ip if protected_route?(protected_ui_mfa_actions, req.path, req.request_method) end - end - EXP_BACKOFF_LEVELS.each do |level| throttle("api/ip/#{level}", limit: EXP_BASE_REQUEST_LIMIT * level, period: (EXP_BASE_LIMIT_PERIOD**level).seconds) do |req| req.ip if protected_route?(protected_api_mfa_actions, req.path, req.request_method) end + + ########################### rate limit per user ########################### + throttle("clearance/user/#{level}", limit: EXP_BASE_REQUEST_LIMIT * level, period: (EXP_BASE_LIMIT_PERIOD**level).seconds) do |req| + if protected_route?(protected_ui_mfa_actions, req.path, req.request_method) + action_dispatch_req = ActionDispatch::Request.new(req.env) + + # otp_create doesn't have remember_token set. use session[:mfa_user] + if protected_route?([otp_create_action], req.path, req.request_method) + action_dispatch_req.session.fetch("mfa_user", "").presence + # password#otp_edit has unique confirmation token + elsif protected_route?([mfa_password_edit_action], req.path, req.request_method) + req.params.fetch("token", "").presence + else + User.find_by_remember_token(action_dispatch_req.cookie_jar.signed["remember_token"])&.email.presence + end + end + end + + ########################### rate limit per api key ########################### + throttle("api/key/#{level}", limit: EXP_BASE_REQUEST_LIMIT * level, period: (EXP_BASE_LIMIT_PERIOD**level).seconds) do |req| + api_key_owner_id(req) if protected_route?(protected_api_mfa_actions, req.path, req.request_method) + end end throttle("owners/ip", limit: REQUEST_LIMIT, period: LIMIT_PERIOD) do |req| @@ -93,6 +151,10 @@ def self.protected_route?(protected_actions, path, method) throttle("#{PUSH_EXP_THROTTLE_KEY}/#{level}", limit: EXP_BASE_REQUEST_LIMIT * level, period: (EXP_BASE_LIMIT_PERIOD**level).seconds) do |req| req.ip if protected_route?(protected_push_action, req.path, req.request_method) end + + throttle("#{PUSH_THROTTLE_PER_USER_KEY}/#{level}", limit: EXP_BASE_REQUEST_LIMIT * level, period: (EXP_BASE_LIMIT_PERIOD**level).seconds) do |req| + api_key_owner_id(req) if protected_route?(protected_push_action, req.path, req.request_method) + end end throttle("api/push/ip", limit: PUSH_LIMIT, period: PUSH_LIMIT_PERIOD) do |req| @@ -123,16 +185,21 @@ def self.protected_route?(protected_actions, path, method) User.normalize_email(req.params['session']['who']).presence if protected_route && req.params['session'] end - protected_api_key_action = [{ controller: "api/v1/api_keys", action: "show" }] - throttle("api_key/basic_auth", limit: REQUEST_LIMIT, period: LIMIT_PERIOD) do |req| - if protected_route?(protected_api_key_action, req.path, req.request_method) + if protected_route?(protected_api_key_actions, req.path, req.request_method) action_dispatch_req = ActionDispatch::Request.new(req.env) who = ActionController::HttpAuthentication::Basic.user_name_and_password(action_dispatch_req).first User.normalize_email(who).presence end end + throttle("password/user", limit: REQUEST_LIMIT, period: LIMIT_PERIOD) do |req| + if protected_route?(protected_password_actions, req.path, req.request_method) + action_dispatch_req = ActionDispatch::Request.new(req.env) + User.find_by_remember_token(action_dispatch_req.cookie_jar.signed["remember_token"])&.email.presence + end + end + ############################# rate limit per email ############################ protected_passwords_action = [{ controller: "passwords", action: "create" }] @@ -142,17 +209,36 @@ def self.protected_route?(protected_actions, path, method) end end - protected_confirmation_action = [{ controller: "email_confirmations", action: "create" }] + protected_confirmation_action = [ + { controller: "email_confirmations", action: "create" }, + { controller: "email_confirmations", action: "unconfirmed" } + ] throttle("email_confirmations/email", limit: REQUEST_LIMIT_PER_EMAIL, period: LIMIT_PERIOD) do |req| - if protected_route?(protected_confirmation_action, req.path, req.request_method) && req.params['email_confirmation'] - User.normalize_email(req.params['email_confirmation']['email']).presence + if protected_route?(protected_confirmation_action, req.path, req.request_method) + if req.params['email_confirmation'] + User.normalize_email(req.params['email_confirmation']['email']).presence + else + action_dispatch_req = ActionDispatch::Request.new(req.env) + User.find_by_remember_token(action_dispatch_req.cookie_jar.signed["remember_token"])&.email.presence + end end end throttle("owners/email", limit: REQUEST_LIMIT_PER_EMAIL, period: LIMIT_PERIOD) do |req| if protected_route?(protected_ui_owners_actions, req.path, req.request_method) - User.find_by_remember_token(req.cookies["remember_token"])&.email.presence + action_dispatch_req = ActionDispatch::Request.new(req.env) + User.find_by_remember_token(action_dispatch_req.cookie_jar.signed["remember_token"])&.email.presence + end + end + + rate_limited_ownership_request_action = [{ controller: "ownership_requests", action: "create" }] + REQUEST_LIMIT_PERIOD = 2.days + + throttle("ownership_requests/email", limit: REQUEST_LIMIT_PER_EMAIL, period: REQUEST_LIMIT_PERIOD) do |req| + if protected_route?(rate_limited_ownership_request_action, req.path, req.request_method) + action_dispatch_req = ActionDispatch::Request.new(req.env) + User.find_by_remember_token(action_dispatch_req.cookie_jar.signed["remember_token"])&.email.presence end end @@ -197,7 +283,7 @@ def self.protected_route?(protected_actions, path, method) } } } - Rails.logger.info event.to_json + Rack::Attack.logger.info 'Rack::Attack Throttling', event.to_json end self.throttled_response_retry_after_header = true diff --git a/config/initializers/request_ip_address.rb b/config/initializers/request_ip_address.rb new file mode 100644 index 00000000000..9f13c0f2ea4 --- /dev/null +++ b/config/initializers/request_ip_address.rb @@ -0,0 +1,5 @@ +Rails.application.config.after_initialize do + ActiveSupport.on_load(:action_dispatch_request) do + include Gemcutter::RequestIpAddress + end +end diff --git a/config/initializers/requires.rb b/config/initializers/requires.rb index 747c81e640b..8a6f441cff6 100644 --- a/config/initializers/requires.rb +++ b/config/initializers/requires.rb @@ -1,8 +1,8 @@ -require 'rubygems/package' -require 'rubygems/indexer' require 'rdoc/markup' require 'rdoc/markup/to_html' require 'patterns' require 'active_support/builder' require 'rack/rewindable_input' require 'elastic_searcher' +require 'github_secret_scanning' +require 'access' diff --git a/config/initializers/rstuf.rb b/config/initializers/rstuf.rb new file mode 100644 index 00000000000..768d2ccee9d --- /dev/null +++ b/config/initializers/rstuf.rb @@ -0,0 +1,7 @@ +require 'rstuf' + +if ENV['RSTUF_API_URL'].presence + Rstuf.base_url = ENV['RSTUF_API_URL'] + Rstuf.enabled = true + Rstuf.wait_for = 10.seconds +end diff --git a/config/initializers/semantic_logger.rb b/config/initializers/semantic_logger.rb new file mode 100644 index 00000000000..52e3cf17a36 --- /dev/null +++ b/config/initializers/semantic_logger.rb @@ -0,0 +1,46 @@ +SemanticLogger.application = "rubygems.org" + +ActiveSupport.on_load(:action_controller) do + def append_info_to_payload(payload) + payload.merge!( + timestamp: Time.now.utc, + env: Rails.env, + network: { + client: { + ip: request.ip + } + } + ) + super + payload[:rails] = { + controller: payload.fetch(:controller), + action: payload.fetch(:action), + params: request.filtered_parameters.except('controller', 'action', 'format', 'utf8'), + format: payload.fetch(:format), + view_time_ms: payload.fetch(:view_runtime, 0.0), + db_time_ms: payload.fetch(:db_runtime, 0.0) + } + payload[:http] = { + request_id: request.uuid, + method: request.method, + status_code: response.status, + response_time_ms: request.url, + useragent: request.user_agent, + url: request.url + } + + method_and_path = [request.method, request.path].compact_blank + method_and_path_string = method_and_path.empty? ? ' ' : " #{method_and_path.join(' ')} " + + payload[:message] ||= "[#{response.status}]#{method_and_path_string}(#{payload.fetch(:controller)}##{payload.fetch(:action)})" + end +end + +class SemanticErrorSubscriber + include SemanticLogger::Loggable + def report(error, handled:, severity:, context:, source: nil) + logger.send severity.to_s.sub(/ing$/, ''), { exception: error, handled:, context:, source: } + end +end + +Rails.error.subscribe(SemanticErrorSubscriber.new) diff --git a/config/initializers/sendgrid.rb b/config/initializers/sendgrid.rb index 3e340199cfb..51a64f8dce7 100644 --- a/config/initializers/sendgrid.rb +++ b/config/initializers/sendgrid.rb @@ -1,4 +1,4 @@ -if Rails.env.production? || Rails.env.staging? +unless Rails.env.local? ActionMailer::Base.smtp_settings = { address: 'smtp.sendgrid.net', port: 587, @@ -6,7 +6,7 @@ password: ENV['SENDGRID_PASSWORD'], domain: 'mailer.rubygems.org', authentication: :plain, - enable_starttls_auto: true + enable_starttls: true } ActionMailer::Base.delivery_method = :smtp end diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb index c3067d97680..445a076ac0d 100644 --- a/config/initializers/session_store.rb +++ b/config/initializers/session_store.rb @@ -5,8 +5,7 @@ # Be sure to restart your server when you modify this file. -options = { key: '_rubygems_session' } -Rails.application.config.session_store :cookie_store, options +Rails.application.config.session_store :cookie_store, key: '_rubygems_session', same_site: :strict # Use the database for sessions instead of the cookie-based default, # which shouldn't be used to store highly confidential information diff --git a/config/initializers/statsd.rb b/config/initializers/statsd.rb index e6efdf7ff2a..a3721b165bb 100644 --- a/config/initializers/statsd.rb +++ b/config/initializers/statsd.rb @@ -1,43 +1,136 @@ -# TODO: add feature to statsd-instrument for default tags -class StatsD::Instrument::Metric - def self.normalize_tags(tags) - tags ||= [] - tags = tags.map { |k, v| k.to_s + ":".freeze + v.to_s } if tags.is_a?(Hash) - tags.map { |tag| tag.tr('|,'.freeze, ''.freeze) } - tags << "env:#{Rails.env}" # Added to allow default env tag on all metrics - end -end +StatsD.logger = SemanticLogger[StatsD] + +StatsD.singleton_client = StatsD.singleton_client.clone_with_options( + default_tags: ["env:#{Rails.env}"] +) -ActiveSupport::Notifications.subscribe(/process_action.action_controller/) do |*args| - event = ActiveSupport::Notifications::Event.new(*args) +ActiveSupport::Notifications.subscribe(/process_action.action_controller/) do |event| event.payload[:format] = event.payload[:format] || 'all' event.payload[:format] = 'all' if event.payload[:format] == '*/*' status = event.payload[:status] - ActiveSupport::Notifications.instrument :performance, + statsd_measure_performance :performance, event.payload.merge(statsd_method: :measure, measurement: 'total_duration', value: event.duration) - ActiveSupport::Notifications.instrument :performance, + statsd_measure_performance :performance, event.payload.merge(statsd_method: :measure, measurement: 'db_time', value: event.payload[:db_runtime]) - ActiveSupport::Notifications.instrument :performance, + statsd_measure_performance :performance, event.payload.merge(statsd_method: :measure, measurement: 'view_time', value: event.payload[:view_runtime]) - ActiveSupport::Notifications.instrument :performance, + statsd_measure_performance :performance, + event.payload.merge(statsd_method: :histogram, + measurement: "allocations", + value: event.allocations) + statsd_measure_performance :performance, event.payload.merge(measurement: "status.#{status}") end -ActiveSupport::Notifications.subscribe(/performance/) do |name, _, _, _, payload| +ActiveSupport::Notifications.subscribe(/\.active_job/) do |event| + job = event.payload[:job] + adapter = event.payload[:adapter] + + statsd_tags = job.statsd_tags.merge( + adapter: adapter.class.name, + error: event.payload[:error]&.class&.name, + exception: event.payload.dig(:exception, 0) + ) + + statsd_measure_performance event.name, + event.payload.merge(statsd_method: :measure, + measurement: 'total_duration', + value: event.duration, + statsd_tags:) + statsd_measure_performance event.name, + event.payload.merge(statsd_method: :histogram, + measurement: "allocations", + value: event.allocations, + statsd_tags:) + statsd_measure_performance event.name, + event.payload.merge( + measurement: statsd_tags[:exception] ? "failure" : "success", + statsd_tags: + ) +end + +ActiveSupport::Notifications.subscribe("perform_job.good_job") do |event| + execution = event.payload[:execution] + # TODO: remove || execution after GoodJob 4 upgrade + job = event.payload[:job] || execution + + result = if event.payload[:retried] || job.retried_good_job_id.present? + :retried + elsif event.payload[:unhandled_error] + :unhandled_error + elsif event.payload[:handled_error] + :handled_error + else + :success + end + + statsd_tags = { + job_class: execution.serialized_params['job_class'], + exception: event.payload.dig(:exception, 0), + queue: execution.queue_name, + priority: job.priority, + result: + } + + statsd_measure_performance event.name, + event.payload.merge(statsd_method: :measure, + measurement: 'total_duration', + value: event.duration, + statsd_tags:) + statsd_measure_performance event.name, + event.payload.merge(statsd_method: :histogram, + measurement: "allocations", + value: event.allocations, + statsd_tags:) + statsd_measure_performance event.name, + event.payload.merge(statsd_method: :histogram, + measurement: "queue_latency", + value: execution.queue_latency, + statsd_tags:) + statsd_measure_performance event.name, + event.payload.merge(statsd_method: :histogram, + measurement: "runtime_latency", + value: execution.runtime_latency, + statsd_tags:) + statsd_measure_performance event.name, + event.payload.merge(statsd_method: :histogram, + measurement: "job_latency", + value: GoodJob::Execution + .where("(serialized_params->>'executions')::integer = 0") + .where(active_job_id: execution.active_job_id) + .pick( + Arel::Nodes.build_quoted(Time.current, GoodJob::Execution.arel_table[:created_at]) - + Arel.sql("COALESCE(scheduled_at, created_at)") + ), + statsd_tags:) + statsd_measure_performance event.name, + event.payload.merge( + measurement: result, + statsd_tags: + ) +end + +def statsd_measure_performance(name, payload) method = payload[:statsd_method] || :increment measurement = payload[:measurement] - value = payload[:value] + value = payload[:value] || 1 key_name = "rails.#{name}.#{measurement}" - StatsD.__send__ method.to_s, + StatsD.__send__ method, key_name, - (value || 1), - tags: ["controller:#{payload[:controller]}", - "action:#{payload[:action]}", - "format:#{payload[:format]}"] + value, + tags: payload.slice(:controller, :action, :format).merge(payload.fetch(:statsd_tags, {})).compact +end + +ActiveSupport::Notifications.subscribe(/performance/) do |name, _, _, _, payload| + statsd_measure_performance(name, payload) +end + +Rails.application.config.after_initialize do + ActiveSupport.on_load(:active_job) { include JobTags } end diff --git a/config/initializers/strong_migrations.rb b/config/initializers/strong_migrations.rb new file mode 100644 index 00000000000..1d977dad858 --- /dev/null +++ b/config/initializers/strong_migrations.rb @@ -0,0 +1,26 @@ +# Mark existing migrations as safe +StrongMigrations.start_after = 20_230_825_182_405 + +# Set timeouts for migrations +# If you use PgBouncer in transaction mode, delete these lines and set timeouts on the database user +StrongMigrations.lock_timeout = 10.seconds +StrongMigrations.statement_timeout = 1.hour + +# Analyze tables after indexes are added +# Outdated statistics can sometimes hurt performance +StrongMigrations.auto_analyze = true + +# Set the version of the production database +# so the right checks are run in development +StrongMigrations.target_version = "13" + +# Add custom checks +# StrongMigrations.add_check do |method, args| +# if method == :add_index && args[0].to_s == "users" +# stop! "No more indexes on the users table" +# end +# end + +# Make some operations safe by default +# See https://github.com/ankane/strong_migrations#safe-by-default +# StrongMigrations.safe_by_default = true diff --git a/config/initializers/turbo.rb b/config/initializers/turbo.rb new file mode 100644 index 00000000000..d7794b6ec78 --- /dev/null +++ b/config/initializers/turbo.rb @@ -0,0 +1,3 @@ +# https://github.com/hotwired/turbo-rails/issues/512 +# +Rails.autoloaders.once.do_not_eager_load("#{Turbo::Engine.root}/app/channels") diff --git a/config/initializers/webauthn.rb b/config/initializers/webauthn.rb new file mode 100644 index 00000000000..59a710c0c86 --- /dev/null +++ b/config/initializers/webauthn.rb @@ -0,0 +1,11 @@ +WebAuthn.configure do |config| + config.origin = if Rails.env.development? + ENV.fetch("WEBAUTHN_ORIGIN", "http://localhost:3000") + elsif Rails.env.test? + "#{Gemcutter::PROTOCOL}://#{Gemcutter::HOST}:31337" + else + "#{Gemcutter::PROTOCOL}://#{Gemcutter::HOST}" + end + config.rp_name = Gemcutter::HOST_DISPLAY + # config.rp_id = Gemcutter::HOST +end diff --git a/config/initializers/yjit.rb b/config/initializers/yjit.rb new file mode 100644 index 00000000000..892911e5672 --- /dev/null +++ b/config/initializers/yjit.rb @@ -0,0 +1,12 @@ +# Automatically enable YJIT if running Ruby 3.3 or newer, +# as it brings very sizeable performance improvements. +# Many users reported 15-25% improved latency. + +# If you are deploying to a memory-constrained environment, +# you may want to delete this file, but otherwise, it's free +# performance. +if defined? RubyVM::YJIT.enable + Rails.application.config.after_initialize do + RubyVM::YJIT.enable + end +end diff --git a/config/initializers/zeitwerk.rb b/config/initializers/zeitwerk.rb new file mode 100644 index 00000000000..cba98d517fc --- /dev/null +++ b/config/initializers/zeitwerk.rb @@ -0,0 +1,15 @@ +# ignore rake tasks because don't need to autoload them +Rails.autoloaders.main.ignore(Rails.root.join("lib/tasks")) + +# does not require autoload. ignore SqsWorker to supress following: +# expected file lib/shoryuken/sqs_worker.rb to define constant Shoryuken::SqsWorker +Rails.autoloaders.main.ignore(Rails.root.join("lib/shoryuken")) + +Rails.autoloaders.main.ignore(Rails.root.join("lib/cops")) + +Rails.autoloaders.main.ignore(Rails.root.join("lib/puma/plugin")) + +Rails.autoloaders.once.inflector.inflect( + "http" => "HTTP", + "oidc" => "OIDC" +) diff --git a/config/locales/avo.en.yml b/config/locales/avo.en.yml new file mode 100644 index 00000000000..35214317872 --- /dev/null +++ b/config/locales/avo.en.yml @@ -0,0 +1,120 @@ +--- +en: + avo: + action_ran_successfully: Action ran successfully! + actions: Actions + and_x_other_resources: and %{count} other resources + are_you_sure: Are you sure? + are_you_sure_detach_item: Are you sure you want to detach this %{item}. + are_you_sure_you_want_to_run_this_option: Are you sure you want to run this action? + attach: Attach + attach_and_attach_another: Attach & Attach another + attach_item: Attach %{item} + attachment_class_attached: "%{attachment_class} attached." + attachment_class_detached: "%{attachment_class} detached." + attachment_destroyed: Attachment destroyed + cancel: Cancel + choose_a_country: Choose a country + choose_an_option: Choose an option + choose_item: Choose %{item} + clear_value: Clear value + click_to_reveal_filters: Click to reveal filters + confirm: Confirm + create_new_item: Create new %{item} + dashboard: Dashboard + dashboards: Dashboards + delete: delete + delete_file: Delete file + delete_item: Delete %{item} + detach_item: detach %{item} + details: details + download: Download + download_file: Download file + download_item: Download %{item} + edit: edit + edit_item: edit %{item} + empty_dashboard_message: Add cards to this dashboard + failed: Failed + failed_to_find_attachment: Failed to find attachment + failed_to_load: Failed to load + field_translations: + file: + one: file + other: files + zero: files + people: + one: peep + other: peeps + zero: peeps + filter_by: Filter by + filters: Filters + go_back: Go back + grid_view: Grid view + hide_content: Hide content + home: Home + key_value_field: + add_row: Add row + delete_row: Delete row + key: Key + value: Value + list_is_empty: List is empty + loading: Loading + more: More + new: new + next_page: Next page + no_cards_present: No cards present + no_item_found: No record found + no_options_available: No options available + no_related_item_found: No related record found + not_authorized: You are not authorized to perform this action. + number_of_items: + one: one %{item} + other: "%{count} %{item}" + zero: no %{item} + oops_nothing_found: Oops! Nothing found... + order: + higher: Move record higher + lower: Move record lower + reorder_record: Reorder record + to_bottom: Move record to bottom + to_top: Move record to top + per_page: Per page + prev_page: Previous page + remove_selection: Remove selection + reset_filters: Reset filters + resource_created: Record created + resource_destroyed: Record destroyed + resource_translations: + user: + one: user + other: users + zero: users + resource_updated: Record updated + resources: Resources + run: Run + save: Save + search: + cancel_button: Cancel + placeholder: Search + select_all: Select all + select_all_matching: Select all matching + select_item: Select item + show_content: Show content + sign_out: Sign out + switch_to_view: Switch to %{view_type} view + table_view: Table view + tools: Tools + type_to_search: Type to search. + unauthorized: Unauthorized + undo: undo + view: View + view_item: view %{item} + was_successfully_created: was successfully created + was_successfully_updated: was successfully updated + x_items_more: + one: one more item + other: "%{count} more items" + zero: no more items + x_records_selected_from_a_total_of_x_html: %{selected} records selected on this page from a total of %{count} + x_records_selected_from_all_pages_html: %{count} records selected from all pages + you_missed_something_check_form: You might have missed something. Please check the form. diff --git a/config/locales/avo.fr.yml b/config/locales/avo.fr.yml new file mode 100644 index 00000000000..404263ed48b --- /dev/null +++ b/config/locales/avo.fr.yml @@ -0,0 +1,120 @@ +--- +fr: + avo: + action_ran_successfully: L'action s'est exécutée avec succès ! + actions: Actions + and_x_other_resources: et %{count} autres ressources + are_you_sure: Êtes-vous sûr ? + are_you_sure_detach_item: Êtes-vous sûr de vouloir détacher %{item}. + are_you_sure_you_want_to_run_this_option: Etes-vous sûr de vouloir exécuter cette action ? + attach: Attacher + attach_and_attach_another: joindre et joindre un autre + attach_item: Attacher %{item} + attachment_class_attached: "%{attachment_class} attaché." + attachment_class_detached: "%{attachment_class} détaché." + attachment_destroyed: Pièce jointe détruite + cancel: Annuler + choose_a_country: Sélectionnez un pays + choose_an_option: Sélectionnez une option + choose_item: Choisir %{item} + clear_value: Effacer la valeur + click_to_reveal_filters: Cliquez pour révéler les filtres + confirm: Confirmer + create_new_item: Créer un nouveau %{item} + dashboard: Tableau de bord + dashboards: Tableaux de bord + delete: supprimer + delete_file: Supprimer le fichier + delete_item: Supprimer %{item} + detach_item: détacher %{item} + details: détails + download: Télécharger + download_file: Télécharger le fichier + download_item: Télécharger %{item} + edit: éditer + edit_item: éditer %{item} + empty_dashboard_message: Ajouter des cartes à ce tableau de bord + failed: Échec + failed_to_find_attachment: Impossible de trouver la pièce jointe + failed_to_load: Impossible de charger + field_translations: + file: + one: fichier + other: fichiers + zero: fichier + people: + one: personne + other: personnes + zero: personne + filter_by: Filtrer par + filters: Filtres + go_back: Retourner en arrière + grid_view: Vue grille + hide_content: Cacher le contenu + home: Accueil + key_value_field: + add_row: Ajouter une ligne + delete_row: Supprimer une ligne + key: Clé + value: Valeur + list_is_empty: La liste est vide + loading: Chargement + more: Plus + new: Nouveau + next_page: Page suivante + no_cards_present: Aucune carte présente + no_item_found: Enregistrement non trouvé + no_options_available: Aucune option disponible + no_related_item_found: Enregistrement connexe introuvable + not_authorized: Vous n'êtes pas autorisé à effectuer cette action. + number_of_items: + one: un %{item} + other: "%{count} %{item}" + zero: aucun %{item} + oops_nothing_found: Oups ! Rien n'a été trouvé... + order: + higher: Déplacer plus haut + lower: Déplacer plus bas + reorder_record: Réorganiser + to_bottom: Déplacer tout en bas + to_top: Déplacer tout en haut + per_page: Par page + prev_page: Page précédente + remove_selection: Supprimer la sélection + reset_filters: Réinitialiser les filtres + resource_created: Ressource créee + resource_destroyed: Ressource détruite + resource_translations: + user: + one: utilisateurs + other: utilisateurs + zero: utilisateur + resource_updated: Ressource mise à jour + resources: Ressources + run: Exécuter + save: Enregistrer + search: + cancel_button: Annuler + placeholder: Rechercher + select_all: Sélectionner tout sur la page + select_all_matching: Sélectionner toutes les correspondances + select_item: Sélectionnez un élément + show_content: Afficher le contenu + sign_out: Se déconnecter + switch_to_view: Passez à la vue %{view_type} + table_view: Vue table + tools: Outils + type_to_search: Type à rechercher. + unauthorized: Non autorisé + undo: annuler + view: Vue + view_item: voir %{item} + was_successfully_created: a été créé avec succès + was_successfully_updated: a été mis à jour avec succès + x_items_more: + one: un élément de plus + other: "%{count} éléments en plus" + zero: aucun élément supplémentaire + x_records_selected_from_a_total_of_x_html: %{selected} enregistrements sélectionnés sur cette page sur un total de %{count} + x_records_selected_from_all_pages_html: %{count} enregistrements sélectionnés dans toutes les pages + you_missed_something_check_form: Vous avez peut-être oublié quelque chose. Veuillez vérifier le formulaire diff --git a/config/locales/avo.nb.yml b/config/locales/avo.nb.yml new file mode 100644 index 00000000000..c4e56d984e5 --- /dev/null +++ b/config/locales/avo.nb.yml @@ -0,0 +1,120 @@ +--- +nb: + avo: + action_ran_successfully: Suksess! + actions: Handlinger + and_x_other_resources: og %{count} andre ressurser + are_you_sure: Er du sikker? + are_you_sure_detach_item: Er du sikker på at du vil koble fra %{item}. + are_you_sure_you_want_to_run_this_option: Er du sikker? + attach: Legg til + attach_and_attach_another: Legg til & Legg til ny + attach_item: Legg til %{item} + attachment_class_attached: "%{attachment_class} lagt til." + attachment_class_detached: "%{attachment_class} fjernet." + attachment_destroyed: Vedlett slettet + cancel: Avbryt + choose_a_country: Velg et land + choose_an_option: Velg et alternativ + choose_item: Velge %{item} + clear_value: Nullstill verdi + click_to_reveal_filters: Vis filter + confirm: Bekreft + create_new_item: Lag ny %{item} + dashboard: Dashboard + dashboards: Dashboards + delete: slett + delete_file: Slett fil + delete_item: Slett %{item} + detach_item: koble fra %{item} + details: detaljer + download: Last ned + download_file: Last ned fil + download_item: Last ned %{item} + edit: endre + edit_item: endre %{item} + empty_dashboard_message: Legg til kort i dette dashbordet + failed: Feilet + failed_to_find_attachment: Fant ikke vedlegg + failed_to_load: Lasting feilet + field_translations: + file: + one: fil + other: filer + zero: filer + people: + one: person + other: personer + zero: personer + filter_by: Filtrer etter + filters: Filter + go_back: Gå tilbake + grid_view: Grid visning + hide_content: Skjul innhold + home: Hjem + key_value_field: + add_row: Legg til rad + delete_row: Slett rad + key: Nøkkel + value: Verdi + list_is_empty: Listen er tom + loading: Laster + more: Mer + new: ny + next_page: Neste side + no_cards_present: Ingen kort til stede + no_item_found: Ingen funnet + no_options_available: Ingen tilgjengelige alternativer + no_related_item_found: Ingen relaterte funnet + not_authorized: Du er ikke autorisert til å gjøre denne handlingen. + number_of_items: + one: en %{item} + other: "%{count} %{item}" + zero: ingen %{item} + oops_nothing_found: Oops! Ingen ting funnet... + order: + higher: Flytt elementet høyere + lower: Flytt elementet lavere + reorder_record: Sorter elementer + to_bottom: Flytt elementet til bunnen + to_top: Flytt elementet til toppen + per_page: Per side + prev_page: Forrige side + remove_selection: Fjern valg + reset_filters: Nullstill filter + resource_created: Ressurs generert + resource_destroyed: Ressurs slettet + resource_translations: + user: + one: bruker + other: brukere + zero: brukere + resource_updated: Ressurs oppdatert + resources: Ressurser + run: Kjør + save: Lagre + search: + cancel_button: Avbryt + placeholder: Søk + select_all: Velg alle + select_all_matching: Velg alle samsvarende + select_item: Velg + show_content: Vis innhold + sign_out: Logg ut + switch_to_view: Bytt til %{view_type} vis + table_view: Tabell visning + tools: Redskapene + type_to_search: Søk. + unauthorized: Ikke autorisert + undo: angre + view: Vis + view_item: vis %{item} + was_successfully_created: ble opprettet + was_successfully_updated: ble oppdatert + x_items_more: + one: ett element til + other: "%{count} flere elementer" + zero: ingen flere elementer + x_records_selected_from_a_total_of_x_html: %{selected} poster valgt på denne siden fra totalt %{count} + x_records_selected_from_all_pages_html: %{count} poster valgt fra alle sider + you_missed_something_check_form: Her mangler du noe. Vennligst sjekk skjemaet. diff --git a/config/locales/avo.nn.yml b/config/locales/avo.nn.yml new file mode 100644 index 00000000000..b82a4798e50 --- /dev/null +++ b/config/locales/avo.nn.yml @@ -0,0 +1,120 @@ +--- +nn: + avo: + action_ran_successfully: Suksess! + actions: Handlingar + and_x_other_resources: og %{count} andre ressursar + are_you_sure: Er du sikker? + are_you_sure_detach_item: Er du sikker på at du vil koble frå %{item}. + are_you_sure_you_want_to_run_this_option: Er du sikker? + attach: Legg til + attach_and_attach_another: Legg til & Legg til ny + attach_item: Legg til %{item} + attachment_class_attached: "%{attachment_class} lagt til." + attachment_class_detached: "%{attachment_class} fjerna." + attachment_destroyed: Vedlegg sletta + cancel: Avbryt + choose_a_country: Vel eit land + choose_an_option: Vel eit alternativ + choose_item: Vel %{item} + clear_value: Nullstill verdi + click_to_reveal_filters: Vis filter + confirm: Stadfest + create_new_item: Lag ny %{item} + dashboard: Dashboard + dashboards: Dashboards + delete: slett + delete_file: Slett fil + delete_item: Slett %{item} + detach_item: koble frå %{item} + details: detaljar + download: Last ned + download_file: Last ned fil + download_item: Last ned %{item} + edit: endre + edit_item: endre %{item} + empty_dashboard_message: Legg til kort i dette dashbordet + failed: Feila + failed_to_find_attachment: Fann ikkje vedlegg + failed_to_load: Lasting feila + field_translations: + file: + one: fil + other: filer + zero: filer + people: + one: person + other: personar + zero: personar + filter_by: Filtrer etter + filters: Filter + go_back: Gå tilbake + grid_view: Gridvisning + hide_content: Skjul innhald + home: Heim + key_value_field: + add_row: Legg til rad + delete_row: Slett rad + key: Nøkkel + value: Verdi + list_is_empty: Lista er tom + loading: Lastar + more: Meir + new: ny + next_page: Neste side + no_cards_present: Ingen kort til stades + no_item_found: Fann ingen + no_options_available: Ingen tilgjengelege alternativ + no_related_item_found: Fann ingen relaterte + not_authorized: Du er ikkje autorisert til å gjere denne handlinga. + number_of_items: + one: en %{item} + other: "%{count} %{item}" + zero: ingen %{item} + oops_nothing_found: Oops! Fann ikkje noko... + order: + higher: Flytt elementet opp + lower: Flytt elementet ned + reorder_record: Sorter element + to_bottom: Flytt elementet til botnen + to_top: Flytt elementet til toppen + per_page: Per side + prev_page: Førre side + remove_selection: Fjern val + reset_filters: Nullstill filter + resource_created: Ressurs generert + resource_destroyed: Ressurs sletta + resource_translations: + user: + one: brukar + other: brukarar + zero: brukarar + resource_updated: Ressurs oppdatert + resources: Ressursar + run: Køyr + save: Lagre + search: + cancel_button: Avbryt + placeholder: Søk + select_all: Vel alle + select_all_matching: Vel alle samsvarende + select_item: Vel + show_content: Vis innhald + sign_out: Logg ut + switch_to_view: Bytt til %{view_type} vis + table_view: Tabellvisning + tools: Reiskapane + type_to_search: Søk. + unauthorized: Ikkje autorisert + undo: angre + view: Vis + view_item: vis %{item} + was_successfully_created: vart oppretta + was_successfully_updated: vart oppdatert + x_items_more: + one: eitt element til + other: "%{count} fleire element" + zero: ingen fleire element + x_records_selected_from_a_total_of_x_html: %{selected} valde postar på denne sida av totalt %{count} + x_records_selected_from_all_pages_html: %{count} valde postar frå alle sider + you_missed_something_check_form: Her manglar du noko. Ver venleg og sjekk skjemaet. diff --git a/config/locales/avo.pt-BR.yml b/config/locales/avo.pt-BR.yml new file mode 100644 index 00000000000..95713d55efc --- /dev/null +++ b/config/locales/avo.pt-BR.yml @@ -0,0 +1,120 @@ +--- +pt-BR: + avo: + action_ran_successfully: Ação executada com sucesso! + actions: Ações + and_x_other_resources: e %{count} outros recursos + are_you_sure: Você tem certeza? + are_you_sure_detach_item: Você tem certeza que deseja separar este %{item}. + are_you_sure_you_want_to_run_this_option: Você tem certeza que deseja executar esta ação? + attach: Anexar + attach_and_attach_another: Anexar & anexar outro + attach_item: Anexar %{item} + attachment_class_attached: "%{attachment_class} anexado." + attachment_class_detached: "%{attachment_class} separado." + attachment_destroyed: Anexo destruído + cancel: Cancelar + choose_a_country: Escolha um país + choose_an_option: Escolha uma opção + choose_item: Escolher %{item} + clear_value: Limpar valor + click_to_reveal_filters: Clique para revelar os filtros + confirm: Confirmar + create_new_item: Criar novo %{item} + dashboard: Painel + dashboards: Painéis + delete: deletar + delete_file: Deletar arquivo + delete_item: Deletar %{item} + detach_item: separar %{item} + details: detalhe + download: Baixar + download_file: Baixar arquivo + download_item: Baixar %{item} + edit: editar + edit_item: editar %{item} + empty_dashboard_message: Adicionar cartões a este painel + failed: Falhou + failed_to_find_attachment: Falhou ao achar anexo + failed_to_load: Falha ao carregar + field_translations: + file: + one: arquivo + other: arquivos + zero: arquivos + people: + one: pessoa + other: pessoas + zero: ninguém + filter_by: Filtrar por + filters: Filtros + go_back: Voltar + grid_view: Visualização em grade + hide_content: Esconder conteúdo + home: Início + key_value_field: + add_row: Adicionar linha + delete_row: Remover linha + key: Chave + value: Valor + list_is_empty: Lista vazia + loading: Carregando + more: Mais + new: novo + next_page: Próxima página + no_cards_present: Nenhum cartão presente + no_item_found: Nenhum registro encontrado + no_options_available: Nenhuma opção disponível + no_related_item_found: Nenhum registro relacionado encontrado + not_authorized: Você não está autorizado à executar essa ação. + number_of_items: + one: um %{item} + other: "%{count} %{item}" + zero: sem %{item} + oops_nothing_found: Oops! Nada encontrado... + order: + higher: Mover registro um para cima + lower: Mover registro um para baixo + reorder_record: Reordenar registro + to_bottom: Mover registro para baixo + to_top: Mover registro para cima + per_page: Por página + prev_page: Página anterior + remove_selection: Remover seleção + reset_filters: Limpar filtros + resource_created: Recurso criado + resource_destroyed: Recurso destruído + resource_translations: + user: + one: usuário + other: usuários + zero: usuários + resource_updated: Recurso atualizado + resources: Recursos + run: Executar + save: Salvar + search: + cancel_button: Cancelar + placeholder: Procurar + select_all: Selecionar tudo + select_all_matching: Selecione todas as correspondências + select_item: Selecionar item + show_content: Mostrar conteúdo + sign_out: sair + switch_to_view: Alterar para visão %{view_type} + table_view: Visualização em tabela + tools: Ferramentas + type_to_search: Digite para buscar. + unauthorized: Não autorizado + undo: desfazer + view: Visualizar + view_item: visualizar %{item} + was_successfully_created: foi criado com sucesso + was_successfully_updated: foi atualizado com sucesso + x_items_more: + one: mais um item + other: "%{count} mais items" + zero: mais nenhum item + x_records_selected_from_a_total_of_x_html: %{selected} registros selecionados nesta página de um total de %{count} + x_records_selected_from_all_pages_html: %{count} registros selecionados de todas as páginas + you_missed_something_check_form: Você pode ter esquecido algo. Por favor, cheque o formulário. diff --git a/config/locales/avo.pt.yml b/config/locales/avo.pt.yml new file mode 100644 index 00000000000..2e02198f5aa --- /dev/null +++ b/config/locales/avo.pt.yml @@ -0,0 +1,120 @@ +--- +pt: + avo: + action_ran_successfully: Ação executada com sucesso! + actions: Ações + and_x_other_resources: e %{count} outros recursos + are_you_sure: Tem a certeza? + are_you_sure_detach_item: Tem a certeza que deseja separar este %{item}. + are_you_sure_you_want_to_run_this_option: Tem a certeza que deseja executar esta ação? + attach: Anexar + attach_and_attach_another: Anexar & anexar outro + attach_item: Anexar %{item} + attachment_class_attached: "%{attachment_class} anexado." + attachment_class_detached: "%{attachment_class} separado." + attachment_destroyed: Anexo destruído + cancel: Cancelar + choose_a_country: Escolha um país + choose_an_option: Escolha uma opção + choose_item: Escolher %{item} + clear_value: Apagar valor + click_to_reveal_filters: Clique para mostrar os filtros + confirm: Confirmar + create_new_item: Criar novo %{item} + dashboard: Painel + dashboards: Painéis + delete: apagar + delete_file: Apagar arquivo + delete_item: Apagar %{item} + detach_item: separar %{item} + details: detalhe + download: Download + download_file: Download do ficheiro + download_item: Download %{item} + edit: editar + edit_item: editar %{item} + empty_dashboard_message: Adicionar cartões a este painel + failed: Falhou + failed_to_find_attachment: Falha ao encontrar anexo + failed_to_load: Falhou ao carregar + field_translations: + file: + one: ficheiro + other: ficheiros + zero: ficheiros + people: + one: pessoa + other: pessoas + zero: ninguém + filter_by: Filtrar por + filters: Filtros + go_back: Voltar + grid_view: Visualização em grelha + hide_content: Esconder conteúdo + home: Início + key_value_field: + add_row: Adicionar linha + delete_row: Apagar linha + key: Chave + value: Valor + list_is_empty: Lista vazia + loading: A carregar + more: Mais + new: novo + next_page: Próxima página + no_cards_present: Nenhum cartão presente + no_item_found: Nenhum registro encontrado + no_options_available: Nenhuma opção disponível + no_related_item_found: Nenhum registro relacionado encontrado + not_authorized: Não está autorizado a executar essa ação. + number_of_items: + one: um %{item} + other: "%{count} %{item}" + zero: sem %{item} + oops_nothing_found: Oops! Nada encontrado... + order: + higher: Mover recurso para cima + lower: Mover recuros para baixo + reorder_record: Reordenar recurso + to_bottom: Mover recurso para o fundo + to_top: Mover recurso para o topo + per_page: Por página + prev_page: Página anterior + remove_selection: Remover seleção + reset_filters: Limpar filtros + resource_created: Recurso criado + resource_destroyed: Recurso destruído + resource_translations: + user: + one: utilizador + other: utilizadores + zero: utilizadores + resource_updated: Recurso atualizado + resources: Recursos + run: Executar + save: Guardar + search: + cancel_button: Cancelar + placeholder: Procurar + select_all: Selecionar tudo + select_all_matching: Selecionar todas as correspondências + select_item: Selecionar item + show_content: Mostrar conteúdo + sign_out: Terminar sessão + switch_to_view: Alterar para visão %{view_type} + table_view: Visualização em tabela + tools: Ferramentas + type_to_search: Escreva para pesquisar. + unauthorized: Não autorizado + undo: desfazer + view: Ver + view_item: ver %{item} + was_successfully_created: foi criado com sucesso + was_successfully_updated: foi atualizado com sucesso + x_items_more: + one: mais um item + other: "%{count} mais items" + zero: mais nenhum item + x_records_selected_from_a_total_of_x_html: %{selected} itens selecionados nesta página de um total de %{count} + x_records_selected_from_all_pages_html: %{count} itens selecionados de todas as páginas + you_missed_something_check_form: Pode ter-se esquecido algo. Por favor, verifique o formulário. diff --git a/config/locales/avo.ro.yml b/config/locales/avo.ro.yml new file mode 100644 index 00000000000..27508de16ab --- /dev/null +++ b/config/locales/avo.ro.yml @@ -0,0 +1,120 @@ +--- +ro: + avo: + action_ran_successfully: Acțiunea a rulat cu succes! + actions: Acțiuni + and_x_other_resources: plus alte %{count} recorduri + are_you_sure: Ești sigur? + are_you_sure_detach_item: Sigur vrei să detașezi asta %{item}. + are_you_sure_you_want_to_run_this_option: Ești sigur că vrei să rulezi aceasta acțiune? + attach: Atașează + attach_and_attach_another: Atașează și atașează inca unul + attach_item: Atașează %{item} + attachment_class_attached: "%{attachment_class} anexat." + attachment_class_detached: "%{attachment_class} separat." + attachment_destroyed: Atașamentul a fost distrus + cancel: Anulează + choose_a_country: Alege o țară + choose_an_option: Alege o opțiune + choose_item: Alege %{item} + clear_value: Șterge valoarea + click_to_reveal_filters: Faceți clic pentru a afișa filtrele + confirm: Confirm + create_new_item: Creează %{item} + dashboard: Panou de control + dashboards: Panouri de control + delete: șterge + delete_file: Șterge fișierul + delete_item: Șterge %{item} + detach_item: detașează %{item} + details: detalii + download: Descarcă + download_file: Descarcă fișier + download_item: Descarcă %{item} + edit: modifică + edit_item: modifică %{item} + empty_dashboard_message: Adaugă carduri în acest dashboard + failed: A eșuat + failed_to_find_attachment: Atașamentul nu a fost găsit + failed_to_load: Încărcarea a eșuat + field_translations: + file: + one: fișier + other: fișiere + zero: fișiere + people: + one: peep + other: peeps + zero: peeps + filter_by: Filtează după + filters: Filtre + go_back: Înapoi + grid_view: Vezi sub formă de grid + hide_content: Ascunde conținutul + home: Acasă + key_value_field: + add_row: Adaugă rând + delete_row: Șterge rând + key: Cheie + value: Valoare + list_is_empty: Lista este goală + loading: Se incarcă + more: Mai multe + new: nou + next_page: Pagina următoare + no_cards_present: Niciun card disponibil + no_item_found: Nici un articol găsit + no_options_available: Nicio opțiune disponibilă + no_related_item_found: Nici un articol asociat găsit + not_authorized: Nu sunteți autorizat să efectuați această acțiune. + number_of_items: + one: un %{item} + other: "%{count} %{item}" + zero: 0 %{item} + oops_nothing_found: Oups! Nu am găsit rezultate... + order: + higher: Mutați înregistrarea mai sus + lower: Mutați înregistrarea mai jos + reorder_record: Reordonați înregistrarea + to_bottom: Mutați înregistrarea jos de tot + to_top: Mutați înregistrarea sus de tot + per_page: Pe pagină + prev_page: Pagina anterioara + remove_selection: Șterge selecția + reset_filters: Resetați filtrele + resource_created: Resursă creata + resource_destroyed: Resursă ștearsă + resource_translations: + user: + one: utilizator + other: utilizatori + zero: utilizatori + resource_updated: Resursă actualizata + resources: Resurse + run: Rulează + save: Salvează + search: + cancel_button: Anulare + placeholder: Caută + select_all: Selectează totul + select_all_matching: Selectează toate care se potrivesc + select_item: Selectează record + show_content: Arată conținutul + sign_out: Delogare + switch_to_view: Comutați la vizualizarea %{view_type} + table_view: Vezi sub formă de tabel + tools: Instrumente + type_to_search: Caută aici... + unauthorized: Neautorizat + undo: Anulează + view: vezi + view_item: vezi %{item} + was_successfully_created: a fost creat + was_successfully_updated: a fost actualizat + x_items_more: + one: încă un articol + other: "%{count} mai multe articole" + zero: zero articole + x_records_selected_from_a_total_of_x_html: %{selected} selectate dintr-un total de %{count} + x_records_selected_from_all_pages_html: %{count} selectate din toate paginile + you_missed_something_check_form: S-ar putea să fi omis ceva. Vă rugăm să verificați formularul. diff --git a/config/locales/avo.tr.yml b/config/locales/avo.tr.yml new file mode 100644 index 00000000000..5b686abc30e --- /dev/null +++ b/config/locales/avo.tr.yml @@ -0,0 +1,120 @@ +--- +tr: + avo: + action_ran_successfully: Eylem başarıyla gerçekleşti! + actions: Aksiyonlar + and_x_other_resources: ve %{count} diğer kaynak + are_you_sure: Emin misiniz? + are_you_sure_detach_item: "%{item} öğesini ayırmak istediğinizden emin misiniz?" + are_you_sure_you_want_to_run_this_option: Bu işlemi gerçekleştirmek istediğinize emin misiniz? + attach: İlişkilendir + attach_and_attach_another: İlişkilendir & Bir başka daha İlişkilendir + attach_item: "%{item} öğesini ilişkilendir" + attachment_class_attached: "%{attachment_class} ilişkilendirildi." + attachment_class_detached: "%{attachment_class} ilişkisi kesildi." + attachment_destroyed: Ek silindi + cancel: İptal et + choose_a_country: Bir ülke seç + choose_an_option: Bir seçenek seç + choose_item: "%{item} seçin" + clear_value: Değeri temizle + click_to_reveal_filters: Filtreleri ortaya çıkarmak için tıklayın + confirm: Onayla + create_new_item: Yeni bir %{item} oluşturun + dashboard: Yönetim Paneli + dashboards: Yönetim Panelleri + delete: sil + delete_file: Dosyayı sil + delete_item: "%{item} öğresini sil" + detach_item: "%{item} öğesinin ilişkisini kes" + details: detaylar + download: İndir + download_file: Dosya indir + download_item: "%{item} öğesini indir" + edit: düzenle + edit_item: "%{item} öğesini düzenle" + empty_dashboard_message: Kartları yönetim paneline ekle + failed: Başarısız + failed_to_find_attachment: Ek bulunamadı + failed_to_load: Yüklenemedi + field_translations: + file: + one: dosya + other: dosya + zero: dosya + people: + one: kişi + other: kişi + zero: kişi + filter_by: Tarafından filtre + filters: Filtreler + go_back: Geri dön + grid_view: Grid görünümü + hide_content: İçeriği gizle + home: Anasayfa + key_value_field: + add_row: Satır ekle + delete_row: Satır sil + key: Anahtar + value: Değer + list_is_empty: Boş liste + loading: Yükleniyor + more: Daha fazla + new: yeni + next_page: Sonraki sayfa + no_cards_present: Kart yok + no_item_found: Hiç bulunamadı + no_options_available: Seçenek yok + no_related_item_found: İlişkili bulunamadı + not_authorized: Bu eylemi gerçekleştirme yetkiniz yok. + number_of_items: + one: bir %{item} + other: "%{count} %{item}" + zero: sıfır %{item} + oops_nothing_found: Hata! Hiçbir şey bulunamadı + order: + higher: Kaydı yüksekten sırala + lower: Kaydı düşükten sırala + reorder_record: Kaydı yeniden sırala + to_bottom: Kaydı en alta taşı + to_top: Kaydı en üste taşı + per_page: Sayfa başına + prev_page: Önceki sayfa + remove_selection: Seçimi sil + reset_filters: Filtreleri sıfırla + resource_created: Kayıt oluşturuldu + resource_destroyed: Kayıt silindi + resource_translations: + user: + one: kullanıcı + other: kullanıcı + zero: kullanıcı + resource_updated: Kayıt güncellendi + resources: Kaynaklar + run: Başlat + save: Kaydet + search: + cancel_button: İptal et + placeholder: Ara + select_all: Tümünü seç + select_all_matching: Tüm eşleşenleri seç + select_item: Öğe seç + show_content: İçeriği göster + sign_out: Çıkış yap + switch_to_view: "%{view_type} görünümüne kay" + table_view: Tablo görünümü + tools: Araçlar + type_to_search: Aramak için yazın. + unauthorized: Yetkisiz + undo: geri al + view: Görünüm + view_item: "%{item} öğresini görüntüle" + was_successfully_created: başarıyla oluşturuldu + was_successfully_updated: başarıyla güncellendi + x_items_more: + one: bir öğe daha + other: "%{count} öğe daha" + zero: daha fazla öğe yok + x_records_selected_from_a_total_of_x_html: %{selected} bu sayfada seçilen toplam kayıtlar %{count} + x_records_selected_from_all_pages_html: %{count} tüm sayfalardan seçilen kayıtlar + you_missed_something_check_form: Bir şeyleri kaçırmış olabilirsiniz. Lütfen formu kontrol edin. diff --git a/config/locales/avo.zh-TW.yml b/config/locales/avo.zh-TW.yml new file mode 100644 index 00000000000..0183c2d0ff4 --- /dev/null +++ b/config/locales/avo.zh-TW.yml @@ -0,0 +1,120 @@ +--- +zh-TW: + avo: + action_ran_successfully: 操作已成功執行! + actions: 操作 + and_x_other_resources: 和 %{count} 個其他資源 + are_you_sure: 您確定嗎? + are_you_sure_detach_item: 您確定要中斷此 %{item} 的連結嗎? + are_you_sure_you_want_to_run_this_option: 您確定要執行此操作嗎? + attach: 連結 + attach_and_attach_another: 連結 & 連結其他 + attach_item: 連結 %{item} + attachment_class_attached: "已連結 %{attachment_class}。" + attachment_class_detached: "已中斷 %{attachment_class} 的連結。" + attachment_destroyed: 已刪除附件 + cancel: 取消 + choose_a_country: 選擇國家 + choose_an_option: 選擇選項 + choose_item: 選擇 %{item} + clear_value: 清除數值 + click_to_reveal_filters: 點擊以顯示篩選器 + confirm: 確認 + create_new_item: 建立新 %{item} + dashboard: 儀表板 + dashboards: 儀表板 + delete: 刪除 + delete_file: 刪除檔案 + delete_item: 刪除 %{item} + detach_item: 中斷 %{item} 的連結 + details: 詳細資料 + download: 下載 + download_file: 下載檔案 + download_item: 下載 %{item} + edit: 編輯 + edit_item: 編輯 %{item} + empty_dashboard_message: 將卡片加入儀表板 + failed: 失敗 + failed_to_find_attachment: 找不到附件 + failed_to_load: 載入失敗 + field_translations: + file: + one: 檔案 + other: 檔案 + zero: 檔案 + people: + one: peep + other: peeps + zero: peeps + filter_by: 篩選方式 + filters: 篩選器 + go_back: 返回 + grid_view: 網格檢視 + hide_content: 隱藏內容 + home: 首頁 + key_value_field: + add_row: 新增行 + delete_row: 刪除行 + key: 索引鍵 + value: 值 + list_is_empty: 列表是空的 + loading: 正在載入 + more: 更多 + new: 新 + next_page: 下一頁 + no_cards_present: 無卡片 + no_item_found: 找不到記錄 + no_options_available: 無可用選項 + no_related_item_found: 找不到相關記錄 + not_authorized: 您無權執行此操作。 + number_of_items: + one: 一個 %{item} + other: "%{count} %{item}" + zero: 無 %{item} + oops_nothing_found: 糟糕!完全找不到... + order: + higher: 將記錄上移 + lower: 將記錄下移 + reorder_record: 重新排列記錄 + to_bottom: 將記錄移至最下方 + to_top: 將記錄移至最上方 + per_page: 每頁 + prev_page: 上一頁 + remove_selection: 移除所選 + reset_filters: 重設篩選器 + resource_created: 已建立記錄 + resource_destroyed: 已刪除記錄 + resource_translations: + user: + one: 使用者 + other: 使用者 + zero: 使用者 + resource_updated: 已更新記錄 + resources: 資源 + run: 執行 + save: 儲存 + search: + cancel_button: 取消 + placeholder: 搜尋 + select_all: 全選 + select_all_matching: 選擇所有相符 + select_item: 選擇項目 + show_content: 顯示內容 + sign_out: 登出 + switch_to_view: 切換至 %{view_type} 檢視 + table_view: 表格檢視 + tools: 工具 + type_to_search: 輸入以搜尋。 + unauthorized: 未經授權 + undo: 復原 + view: 檢視 + view_item: 檢視 %{item} + was_successfully_created: 已成功建立 + was_successfully_updated: 已成功更新 + x_items_more: + one: 還有一個項目 + other: "還有 %{count} 個項目" + zero: 沒有項目了 + x_records_selected_from_a_total_of_x_html: 已從此頁面的 %{count} 項記錄中選取了 %{selected} 項 + x_records_selected_from_all_pages_html: 從所有頁面選取了 %{count} 項記錄 + you_missed_something_check_form: 您似乎漏掉了什麼。請檢查表單。 diff --git a/config/locales/de.yml b/config/locales/de.yml index 246bbb9f9cf..6a2a3412b72 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -1,32 +1,59 @@ +--- de: + credentials_required: + copied: Kopiert! + copy_to_clipboard: In die Zwischenablage kopieren edit: Bearbeiten - failure_when_forbidden: + verification_expired: feed_latest: RubyGems.org | Neueste Gems feed_subscribed: RubyGems.org | Abonnierte Gems - footer_about_html: - footer_sponsors_html: - footer_join_rt_html: + footer_about_html: RubyGems.org ist der Gem-Hosting-Dienst der Ruby-Community. Veröffentlichen Sie Ihre Gems sofort und installieren + Sie sie dann. Verwenden Sie die API, um mehr über + verfügbare Gems herauszufinden. Werden + Sie ein Mitwirkender und verbessern Sie die Website selbst. + footer_sponsors_html: RubyGems.org wird durch eine Partnerschaft mit der breiteren + Ruby-Gemeinschaft ermöglicht. Fastly, der + Anbieter von Bandbreite und CDN-Support, Ruby + Central, das die Infrastrukturkosten trägt, und die Finanzierung der Entwicklung + und Verwaltung der Server übernimmt. Erfahren Sie mehr + über unsere Sponsoren und wie sie zusammenarbeiten. + footer_join_rt_html: Wir benötigen Ihre Hilfe, um die Entwicklerzeit zu finanzieren, + die RubyGems.org für alle reibungslos am Laufen hält. Treten + Sie heute Ruby Central bei. form_disable_with: Bitte warten... - invalid_page: + invalid_page: Die Seitennummer liegt außerhalb des Bereichs. Umleitung zur Standardseite. locale_name: Deutsch - none: - not_found: + none: None + not_found: Nicht gefunden + forbidden: + api_gem_not_found: api_key_forbidden: - please_sign_up: Zugriff verweigert. Bitte melden Sie sich unter https://rubygems.org mit einem Konto an - please_sign_in: - otp_incorrect: - otp_missing: + api_key_soft_deleted: + api_key_insufficient_scope: + please_sign_up: Zugriff verweigert. Bitte melden Sie sich unter https://rubygems.org + mit einem Konto an + please_sign_in: Bitte melden Sie sich an, um fortzufahren. + otp_incorrect: Ihr OTP-Code ist falsch. Bitte überprüfen Sie ihn und versuchen Sie + es erneut. + otp_missing: Sie haben die Mehrfaktor-Authentifizierung aktiviert, aber keinen OTP-Code + angegeben. Bitte füllen Sie diesen aus und versuchen Sie es erneut. sign_in: Anmelden sign_up: Registrieren - dependency_list: - multifactor_authentication: + dependency_list: Alle transitiven Abhängigkeiten anzeigen + multifactor_authentication: Mehrfaktorauthentifizierung subtitle: Ihre Community des Gem-Hostingservices this_rubygem_could_not_be_found: Dieses Ruby Gem konnte nicht gefunden werden. time_ago: seit %{duration} title: RubyGems.org update: Aktualisieren - try_again: - advanced_search: + try_again: Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut. + advanced_search: Erweiterte Suche + authenticate: Authentifizieren + helpers: + submit: + create: Erstelle %{model} + update: Aktualisiere %{model} activerecord: attributes: linkset: @@ -36,56 +63,148 @@ de: docs: Dokumentation URL mail: Mailingliste URL wiki: Wiki URL - funding: + funding: Finanzierungs-URL session: password: Passwort who: E-Mail oder Benutzername user: avatar: Avatar email: E-Mail-Adresse + full_name: Vollständiger Name handle: Benutzername password: Passwort + ownership/role: + owner: + admin: + maintainer: + api_key: + oidc_api_key_role: OIDC-API-Schlüsselrolle + oidc/id_token: + jti: + api_key_role: API-Schlüsselrolle + oidc/api_key_role: + api_key_permissions: API-Schlüsselberechtigungen + oidc/trusted_publisher/github_action: + repository_owner_id: GitHub Repository-Besitzer-ID + oidc/pending_trusted_publisher: + rubygem_name: RubyGem-Name errors: messages: - unpwn: - blocked: + unpwn: ist bereits in einem Datenleck aufgetaucht und sollte nicht verwendet + werden + blocked: Die Domain '%{domain}' wurde wegen Spam blockiert. Bitte verwenden + Sie eine gültige persönliche E-Mail-Adresse. + models: + api_key: + attributes: + expires_at: + inclusion: + organization: + attributes: + handle: + invalid: + ownership: + attributes: + user_id: + already_confirmed: ist bereits Eigentümer dieses Gems + already_invited: wurde bereits zu diesem Gem eingeladen + ownership_request: + attributes: + user_id: + taken: + existing: + user: + attributes: + handle: + invalid: + version: + attributes: + gem_full_name: + taken: "%{value} existiert bereits" + full_name: + taken: "%{value} existiert bereits" + oidc/rubygem_trusted_publisher: + attributes: + rubygem: + taken: wurde bereits mit diesem vertrauenswürdigen Herausgeber konfiguriert + oidc/pending_trusted_publisher: + attributes: + rubygem_name: + unavailable: wird bereits verwendet + models: + user: Benutzer + api_key: + zero: API-Schlüssel + one: API-Schlüssel + other: API-Schlüssel + activemodel: + attributes: + oidc/provider/configuration: + jwks_uri: JWKS-URI + id_token_signing_alg_values_supported: Unterstützte ID-Token-Signaturalgorithmen + errors: + models: + oidc/api_key_permissions: + attributes: + valid_for: + inclusion: "%{value} Sekunden müssen zwischen 5 Minuten (300 Sekunden) + und 1 Tag (86.400 Sekunden) liegen" + gems: + too_long: darf höchstens 1 Gem enthalten api_keys: + form: + exclusive_scopes: Exklusive Berechtigungen + rubygem_scope: Gem-Bereich + rubygem_scope_info: Dieser Bereich beschränkt die Befehle gem push/yank und + owner add/remove auf ein bestimmtes Gem. + multifactor_auth: Mehrfaktor-Authentifizierung + enable_mfa: MFA aktivieren + expiration: create: - success: + success: Neuen API-Schlüssel erstellt + invalid_gem: Das ausgewählte Gem kann diesem Schlüssel nicht zugeordnet werden destroy: - success: + success: 'API-Schlüssel erfolgreich gelöscht: %{name}' index: - api_keys: - name: - scopes: - age: - last_access: - action: - delete: - confirm: - confirm_all: - new_key: - index_rubygems: - push_rubygem: - yank_rubygem: - add_owner: - remove_owner: - access_webhooks: - show_dashboard: - reset: - save_key: + api_keys: API-Schlüssel + name: Name + scopes: Berechtigungen + gem: Gem + age: Alter + last_access: Letzter Zugriff + action: Aktion + delete: Löschen + confirm: Der API-Schlüssel wird ungültig. Sind Sie sicher? + confirm_all: Alle API-Schlüssel werden ungültig. Sind Sie sicher? + new_key: Neuer API-Schlüssel + index_rubygems: Rubygems indizieren + push_rubygem: Rubygem veröffentlichen + yank_rubygem: Rubygem zurückziehen + add_owner: Besitzer hinzufügen + remove_owner: Besitzer entfernen + access_webhooks: Webhooks zugreifen + show_dashboard: Dashboard anzeigen + configure_trusted_publishers: + reset: Zurücksetzen + save_key: 'Beachten Sie, dass wir Ihnen den Schlüssel nicht erneut anzeigen + können. Neuer API-Schlüssel:' + mfa: MFA + expiration: + update_owner: new: - new_api_key: + new_api_key: Neuer API-Schlüssel reset: - success: + success: Alle API-Schlüssel gelöscht update: - success: + success: API-Schlüssel erfolgreich aktualisiert + invalid_gem: Das ausgewählte Gem kann diesem Schlüssel nicht zugeordnet werden edit: - edit_api_key: - clearance_mailer: - change_password: - title: - subtitle: + edit_api_key: API-Schlüssel bearbeiten + invalid_key: Ein ungültiger API-Schlüssel kann nicht bearbeitet werden. Bitte + löschen Sie ihn und erstellen Sie einen neuen. + all_gems: Alle Gems + gem_ownership_removed: Die Eigentümerschaft von %{rubygem_name} wurde nach der + Zuordnung zu diesem Schlüssel entfernt. dashboards: show: creating_link_text: Erstellen @@ -95,18 +214,26 @@ de: migrating_link_text: Migrieren mine: Meine Gems my_subscriptions: Abonnements - no_owned_html: Du hast noch keine Gems gepusht. Schau doch vielleicht die Dokumentation auf %{creating_link} an und %{migrating_link} ein Gem from RubyForge. - no_subscriptions_html: Du hast noch keine Gems abonniert. Besuche %{gem_link} um das Gem zu abonnieren! + no_owned_html: Du hast noch keine Gems gepusht. Schau doch vielleicht die Dokumentation + auf %{creating_link} an und %{migrating_link} ein Gem from RubyForge. + no_subscriptions_html: Du hast noch keine Gems abonniert. Besuche %{gem_link} + um das Gem zu abonnieren! title: Dashboard + dependencies: + show: + click_to_expand: Klicken Sie auf das Pfeilsymbol, um es zu erweitern. email_confirmations: create: - promise_resend: + promise_resend: Wir senden Ihnen einen Bestätigungslink per E-Mail, um Ihr Konto + zu aktivieren, falls eines vorhanden ist. new: - submit: - title: - will_email_notice: + submit: Bestätigung erneut senden + title: E-Mail zur Bestätigung erneut senden + will_email_notice: Wir senden Ihnen einen Bestätigungslink per E-Mail, um Ihr + Konto zu aktivieren. update: - confirmed_email: + confirmed_email: Ihre E-Mail-Adresse wurde verifiziert. + token_failure: Bitte überprüfen Sie die URL oder versuchen Sie es erneut. home: index: downloads_counting_html: Gezählte Downloads @@ -138,139 +265,341 @@ de: tested_by: tracking_by: Getrackt von uptime: Betriebszeit + verified_by: + secured_by: + looking_for_maintainers: header: dashboard: Dashboard - edit_profile: settings: + edit_profile: search_gem_html: Suche Gems… sign_in: Anmelden sign_out: Abmelden sign_up: Registrieren + mfa_banner_html: mailer: - confirm_your_email: - confirmation_subject: - link_expiration_explanation_html: + confirm_your_email: Bitte bestätigen Sie Ihre E-Mail-Adresse mit dem Link, der + an Ihre E-Mail gesendet wurde. + confirmation_subject: Bitte bestätigen Sie Ihre E-Mail-Adresse mit %{host} + link_expiration_explanation_html: Bitte beachten Sie, dass dieser Link nur 3 Stunden + gültig ist. Sie können den aktualisierten Link über die Seite Bestätigungsmail + erneut senden anfordern. email_confirmation: - title: - subtitle: - confirmation_link: - welcome_message: + title: E-MAIL-BESTÄTIGUNG + subtitle: Fast geschafft! + confirmation_link: E-Mail-Adresse bestätigen + welcome_message: Willkommen bei %{host}! Besuchen Sie den unten stehenden Link, + um Ihre E-Mail zu verifizieren. email_reset: - title: - subtitle: - visit_link_instructions: + title: E-MAIL-ZURÜCKSETZEN + subtitle: Hallo %{handle}! + visit_link_instructions: Sie haben Ihre E-Mail-Adresse auf %{host} geändert. + Bitte besuchen Sie die folgende URL, um Ihr Konto erneut zu aktivieren. deletion_complete: - title: - subtitle: - subject: - body_html: + title: LÖSCHUNG ABGESCHLOSSEN + subtitle: Tschüss! + subject: Ihr Konto wurde von %{host} gelöscht + body_html: Ihr Antrag auf Kontolöschung auf %{host} wurde bearbeitet. Sie können + jederzeit ein neues Konto über unsere %{sign_up}-Seite erstellen. deletion_failed: - title: - subtitle: - subject: - body_html: + title: LÖSCHUNG FEHLGESCHLAGEN + subtitle: Entschuldigung! + subject: Ihr Antrag auf Kontolöschung auf %{host} ist fehlgeschlagen + body_html: Sie hatten eine Kontolöschung auf %{host} beantragt. Leider konnten + wir Ihren Antrag nicht bearbeiten. Bitte versuchen Sie es nach einiger Zeit + erneut oder kontaktieren Sie uns, wenn das Problem weiterhin besteht. notifiers_changed: - subject: - title: - subtitle: - 'on': - off_html: + subject: Sie haben Ihre E-Mail-Benachrichtigungseinstellungen für %{host} geändert + title: E-MAIL-BENACHRICHTIGUNGEN + subtitle: Hallo %{handle} + 'on': AKTIVIERT + off_html: "DEAKTIVIERT" gem_pushed: - subject: - title: + subject: Gem %{gem} auf %{host} veröffentlicht + title: GEM VERÖFFENTLICHT + gem_yanked: + subject: Gem %{gem} von %{host} zurückgezogen + title: GEM ZURÜCKGEZOGEN reset_api_key: - subject: - title: - subtitle: + subject: "%{host} API-Schlüssel wurde zurückgesetzt" + title: API-SCHLÜSSEL ZURÜCKGESETZT + subtitle: Hallo %{handle} + webauthn_credential_created: + subject: Neue Sicherheitsvorrichtung hinzugefügt + title: SICHERHEITS GERÄT HINZUGEFÜGT + subtitle: Hallo %{handle}! + webauthn_credential_removed: + subject: Sicherheitsgerät entfernt auf %{host} + title: SICHERHEITSGERÄT ENTFERNT + subtitle: Hallo %{handle}! + totp_enabled: + subject: Authentifizierungs-App auf %{host} aktiviert + title: AUTHENTIFIZIERUNGS-APP AKTIVIERT + subtitle: Hallo %{handle}! + totp_disabled: + subject: Authentifizierungs-App auf %{host} deaktiviert + title: AUTHENTIFIZIERUNGS-APP DEAKTIVIERT + subtitle: Hallo %{handle}! email_reset_update: - subject: - title: - gem_yanked: - subject: - title: + subject: Sie haben eine Aktualisierung der E-Mail-Adresse auf %{host} angefordert + title: E-MAIL-AKTUALISIERUNG ANGEFORDERT ownership_confirmation: - subject: - title: - subtitle: - body_html: - link_expiration_explanation_html: + subject: Bitte bestätigen Sie die Eigentümerschaft des Gems %{gem} auf %{host} + title: EIGENTÜMERBESTÄTIGUNG + subtitle: Hallo %{handle}! + body_text: Sie wurden von %{authorizer} als Eigentümer des Gems %{gem} hinzugefügt. + Bitte besuchen Sie den unten stehenden Link, um Ihre Eigentümerschaft zu bestätigen. + body_html: Sie wurden von %{authorizer} + als Eigentümer des Gems %{gem} + hinzugefügt. Bitte klicken Sie auf den unten stehenden Link, um Ihre Eigentümerschaft + zu bestätigen. + link_expiration_explanation_html: Bitte beachten Sie, dass dieser Link nur %{expiry_hours} + gültig ist. Sie können die Bestätigungsmail von der Seite %{gem} + erneut senden, nachdem Sie sich angemeldet haben. owner_added: - subject_self: - subject_others: - title: - subtitle: - body_self_html: - body_others_html: + subject_self: Sie wurden als Eigentümer des Gems %{gem} hinzugefügt + subject_others: Benutzer %{owner_handle} wurde als Eigentümer des Gems %{gem} + hinzugefügt + title: EIGENTÜMER HINZUGEFÜGT + subtitle: Hallo %{user_handle}! + body_self_html: Sie wurden als Eigentümer des Gems %{gem} + auf %{host} hinzugefügt. + body_others_html: %{owner_handle} wurde als Eigentümer des Gems %{gem} von %{authorizer} + hinzugefügt. Sie erhalten diese Benachrichtigung, weil Sie ein Eigentümer + von %{gem} sind. owner_removed: + subject: Du wurdest als Besitzer vom %{gem}-Gem entfernt + title: BESITZER ENTFERNT + subtitle: Hallo %{user_handle}! + body_html: Du wurdest als Besitzer für das %{gem}-Gem + auf %{host} von %{remover} entfernt. + owner_updated: subject: title: subtitle: body_html: + body_text: + ownerhip_request_closed: + title: ANFRAGE ZUR BESITZERRECHTIGUNG + subtitle: Hallo %{handle}! + body_html: Vielen Dank, dass du dich um die Besitzerrechte für das %{gem}-Gem + beworben hast. Leider müssen wir dir mitteilen, dass deine Anfrage vom Gem-Besitzer + geschlossen wurde. + ownerhip_request_approved: + body_html: Herzlichen Glückwunsch! Deine Anfrage zur Besitzerrechte für das + %{gem}-Gem wurde genehmigt. Du wurdest als Besitzer zum Gem + hinzugefügt. + new_ownership_requests: + body_html: + zero: Es gibt keine neuen Anfragen zur Besitzerrechte für das %{gem}-Gem. + one: Es gibt eine neue Anfrage zur Besitzerrechte für das %{gem}-Gem. + Bitte klicke auf den unten stehenden Button, um sie anzuzeigen. + other: Es gibt %{count} neue Anfragen zur Besitzerrechte für das + %{gem}-Gem. Bitte klicke auf den unten stehenden Button, + um alle Anfragen anzuzeigen. + button: BESITZERRECHTE + disable_notifications: Um diese Nachrichten nicht mehr zu erhalten, aktualisiere + deine + owners_page: BESITZERRECHTE + web_hook_deleted: + title: WEBHOOK GELÖSCHT + subject: Dein %{host}-Webhook wurde gelöscht + subtitle: Hallo %{handle}! + body_text: Dein Webhook, der an %{url} gepostet hat, wurde nach %{failures} + Fehlversuchen gelöscht. + body_html: Dein Webhook, der an %{url} gepostet hat, wurde nach + %{failures} Fehlversuchen gelöscht. + global_text: Dieser Webhook wurde zuvor aufgerufen, wenn ein beliebiges Gem + gepusht wurde. + global_html: Dieser Webhook wurde zuvor aufgerufen, wenn irgendein + Gem gepusht wurde. + gem_text: Dieser Webhook wurde zuvor aufgerufen, wenn %{gem} gepusht wurde. + gem_html: Dieser Webhook wurde zuvor aufgerufen, wenn %{gem} + gepusht wurde. + web_hook_disabled: + title: WEBHOOK DEAKTIVIERT + subject: Dein %{host}-Webhook wurde deaktiviert + subtitle: Hallo %{handle}! + body_text: | + Dein Webhook, der an %{url} gepostet hat, wurde aufgrund von %{disabled_reason} deaktiviert. + Er war zuletzt erfolgreich um %{last_success} und ist seitdem %{failures_since_last_success} Mal fehlgeschlagen. + Du kannst diesen Webhook löschen, indem du `%{delete_command}` ausführst. + body_html: | +

          Dein Webhook, der an %{url} gepostet hat, wurde aufgrund von %{disabled_reason} deaktiviert.

          +

          Er war zuletzt erfolgreich um %{last_success} und ist seitdem %{failures_since_last_success} Mal fehlgeschlagen.

          +

          Du kannst diesen Webhook löschen, indem du %{delete_command} ausführst.

          + global_text: Dieser Webhook wurde zuvor aufgerufen, wenn ein beliebiges Gem + gepusht wurde. + global_html: Dieser Webhook wurde zuvor aufgerufen, wenn irgendein + Gem gepusht wurde. + gem_text: Dieser Webhook wurde zuvor aufgerufen, wenn %{gem} gepusht wurde. + gem_html: Dieser Webhook wurde zuvor aufgerufen, wenn %{gem} + gepusht wurde. + gem_trusted_publisher_added: + title: VERTRAUENSWÜRDIGER PUBLISHER HINZUGEFÜGT + admin_manual: + title: news: show: - title: - all_gems: - popular_gems: + title: Neue Veröffentlichungen — Alle Gems + all_gems: Alle Gems + popular_gems: Beliebte Gems popular: - title: + title: Neue Veröffentlichungen — Beliebte Gems pages: about: - founding_html: Das Projekt wurde im April 2009 von %{founder} gestartet und hat seitdem über %{contributors} und %{downloads}. Ab dem Release von RubyGems 1.3.6 wurde die Website von Gemcutter zu %{title} umbenannt, um eine zentrale Rolle in der Ruby-Community zu verfestigen. - support_html: Obwohl RubyGems.org nicht von einem bestimmten Unternehmen ausgeführt wird, haben viele Unternehmen uns bereits unterstützt. Das aktuelle Design, die Illustrationen und die Front-End-Entwicklung für dieser Webseite wurden von %{dockyard} erstellt. %{github} hat auch von unschätzbarem Wert uns geholfen den Code mit anderen Entwicklern zuteilen, um an dem Code leichter zusammenarbeiten. Die Website wurde mit %{heroku} gestartet, dessen großen Dienst uns geholfen, RubyGems als eine praktikable Lösung zu erweisen, wo die ganze Gemeinschaft darauf verlassen kann. Our infrastructure is currently hosted on %{aws}. - technical_html: 'Einige Einblicke in die technischen Aspekte dieser Webseite: Es ist 100% in Ruby geschrieben. Die Hauptseite ist verwendet die %{rails}-Software. Die Gems werden in %{s3}, served by %{fastly}, gehostet und die Zeit zwischen der Veröffentlichung eines neuen Gems und nachdem er bereit zur Installation freigegeben ist, ist minimal. Für mehr Informationen siehe %{source_code} (%{license}) auf GitHub.' + contributors_amount: "%{count} Rubyists" + downloads_amount: Millionen von Gem-Downloads + checkout_code: bitte prüfe den Code + mit_licensed: MIT-lizenziert + logo_header: Auf der Suche nach unserem Logo? + logo_details: Wähle einfach den Download-Button, um drei .PNGs und eine .SVG + des RubyGems-Logos für dich zu erhalten. + founding_html: Das Projekt wurde im April 2009 von %{founder} gestartet und + hat seitdem über %{contributors} und %{downloads}. Ab dem Release von RubyGems + 1.3.6 wurde die Website von Gemcutter zu %{title} umbenannt, um eine zentrale + Rolle in der Ruby-Community zu verfestigen. + support_html: Obwohl RubyGems.org nicht von einem bestimmten Unternehmen ausgeführt + wird, haben viele Unternehmen uns bereits unterstützt. Das aktuelle Design, + die Illustrationen und die Front-End-Entwicklung für dieser Webseite wurden + von %{dockyard} erstellt. %{github} hat auch von unschätzbarem Wert uns geholfen + den Code mit anderen Entwicklern zuteilen, um an dem Code leichter zusammenarbeiten. + Die Website wurde mit %{heroku} gestartet, dessen großen Dienst uns geholfen, + RubyGems als eine praktikable Lösung zu erweisen, wo die ganze Gemeinschaft + darauf verlassen kann. Our infrastructure is currently hosted on %{aws}. + technical_html: 'Einige Einblicke in die technischen Aspekte dieser Webseite: + Es ist 100% in Ruby geschrieben. Die Hauptseite ist verwendet die %{rails}-Software. + Die Gems werden in %{s3}, served by %{fastly}, gehostet und die Zeit zwischen + der Veröffentlichung eines neuen Gems und nachdem er bereit zur Installation + freigegeben ist, ist minimal. Für mehr Informationen siehe %{source_code} + (%{license}) auf GitHub.' title: Über uns purpose: better_api: Bereitstellen eines besseren APIs für den Umgang mit Gems - enable_community: Der Gemeinschaft ermöglichen, die Webseite zu verbessern und zu erweitern - header: 'Willkommen in %{site}, die Community für Ruby Gem-Hostingservice. Die Gründe sind vielfältig:' + enable_community: Der Gemeinschaft ermöglichen, die Webseite zu verbessern + und zu erweitern + header: 'Willkommen in %{site}, die Community für Ruby Gem-Hostingservice. + Die Gründe sind vielfältig:' transparent_pages: Erstellen transparenter und zugänglicher Projektseiten data: - title: + title: RubyGems.org Data Dumps download: - title: + title: Download RubyGems faq: - title: + title: FAQ migrate: - title: + title: Migrate gems security: - title: + title: Security sponsors: - title: + title: Sponsors + password_mailer: + change_password: + closing: If you didn't request this, ignore this email. Your password has not + been changed. + opening: 'Someone, hopefully you, requested we send you a link to change your + password:' + title: CHANGE PASSWORD + subtitle: Hi %{handle}! passwords: edit: submit: Speichern dieses Passworts title: Zurücksetzen des Passworts + token_failure: new: submit: Zurücksetzen des Passworts title: Ändere dein Passwort will_email_notice: Wir senden dir einen Link damit du dein Passwort ändern kannst. - otp_prompt: - authenticate: + create: + success: In den nächsten Minuten erhältst du eine E-Mail mit Anweisungen zum + Ändern deines Passworts. + failure_on_missing_email: Die E-Mail darf nicht leer sein. + update: + failure: Dein Passwort konnte nicht geändert werden. Bitte versuche es erneut. multifactor_auths: - incorrect_otp: - otp_code: - require_mfa_disabled: - require_mfa_enabled: + session_expired: Deine Sitzung auf der Anmeldeseite ist abgelaufen. + require_mfa_enabled: Deine Zwei-Faktor-Authentifizierung ist nicht aktiviert. + Du musst sie zuerst aktivieren. + require_webauthn_enabled: Du hast keine Sicherheitsgeräte aktiviert. Du musst + zuerst ein Gerät mit deinem Konto verknüpfen. + setup_required_html: Zum Schutz deines Kontos und deiner Gems musst du die Zwei-Faktor-Authentifizierung + einrichten. Bitte lies unseren Blog-Beitrag + für weitere Details. + setup_recommended: Zum Schutz deines Kontos und deiner Gems empfehlen wir dir, + die Zwei-Faktor-Authentifizierung einzurichten. In Zukunft wird dein Konto verpflichtend + MFA aktiviert haben. + strong_mfa_level_required_html: Zum Schutz deines Kontos und deiner Gems musst + du dein MFA-Level auf "UI und Gem-Anmeldung" oder "UI und API" ändern. Bitte + lies unseren Blog-Beitrag + für weitere Details. + strong_mfa_level_recommended: Zum Schutz deines Kontos und deiner Gems empfehlen + wir dir, dein MFA-Level auf "UI und Gem-Anmeldung" oder "UI und API" zu ändern. + In Zukunft wird dein Konto verpflichtend MFA auf einem dieser Level aktiviert + haben. + setup_webauthn_html: "\U0001F389 Wir unterstützen jetzt Sicherheitsgeräte! Verbessere + die Sicherheit deines Kontos, indem du ein + neues Gerät einrichtest. Erfahre + mehr!" + api: + mfa_required: + mfa_required_not_yet_enabled: + mfa_required_weak_level_enabled: + mfa_recommended_not_yet_enabled: + mfa_recommended_weak_level_enabled: + recovery: + continue: Weiter + title: Wiederherstellungscodes + saved: Ich bestätige, dass ich meine Wiederherstellungscodes gespeichert habe. + confirm_dialog: + note_html: Bitte kopieren und speichern + Sie diese Wiederherstellungscodes. Sie können diese Codes verwenden, um sich + anzumelden und Ihre MFA zurückzusetzen, wenn Sie Ihr Authentifizierungsgerät + verlieren. Jeder Code kann nur einmal verwendet werden. + already_generated: Sie sollten Ihre Wiederherstellungscodes bereits gespeichert + haben. + update: + invalid_level: Ungültiges MFA-Level. + success: Sie haben Ihr Multi-Faktor-Authentifizierungslevel erfolgreich aktualisiert. + prompt: + webauthn_credential_note: Authentifizieren Sie sich mit einem Sicherheitsgerät + wie Touch ID, YubiKey usw. + sign_in_with_webauthn_credential: Authentifizieren Sie sich mit einem Sicherheitsgerät + otp_code: OTP-Code + otp_or_recovery: OTP- oder Wiederherstellungscodes + recovery_code: Wiederherstellungscodes + recovery_code_html: Sie können einen gültigen Wiederherstellungscodes verwenden, wenn Sie den Zugriff + auf Ihr Multi-Faktor-Authentifizierungsgerät oder Ihr Sicherheitsgerät verloren + haben. + security_device: Sicherheitsgerät + verify_code: Verifizieren Sie den Code + totps: + incorrect_otp: Dein OTP-Code ist falsch. + require_totp_disabled: Deine auf OTP basierende Zwei-Faktor-Authentifizierung + ist bereits aktiviert. Um sie neu zu konfigurieren, musst du sie zuerst entfernen. + require_totp_enabled: Du hast keine Authenticator-App aktiviert. Du musst sie + zuerst aktivieren. new: - title: - scan_prompt: - otp_prompt: - confirm: - enable: - account: - key: - time_based: + title: Aktivierung der Zwei-Faktor-Authentifizierung + scan_prompt: Bitte scannen Sie den QR-Code mit Ihrer Authenticator-App. Wenn + Sie den Code nicht scannen können, fügen Sie die Informationen unten manuell + in Ihre App ein. + otp_prompt: Geben Sie den Zifferncode aus der Authentifizierungs-App ein, um + fortzufahren. + confirm: Ich habe meine Wiederherstellungscodes sicher aufbewahrt. + enable: Aktivieren + account: 'Konto: %{account}' + key: 'Schlüssel: %{key}' + time_based: 'Zeitbasiert: Ja' create: - qrcode_expired: - success: - recovery: - continue: - title: - note: + qrcode_expired: Der QR-Code und der Schlüssel sind abgelaufen. Bitte versuchen + Sie, ein neues Gerät zu registrieren. + success: Sie haben die OTP-basierte Zwei-Faktor-Authentifizierung erfolgreich + aktiviert. destroy: - success: - update: - success: + success: Sie haben die OTP-basierte Zwei-Faktor-Authentifizierung erfolgreich + deaktiviert. notifiers: update: success: @@ -281,26 +610,88 @@ de: recommended: title: update: - push_heading: owner_heading: + owner_request_heading: + push_heading: + webauthn_verifications: + expired_or_already_used: + no_port: + pending: + prompt: + title: + authenticating_as: + authenticate: + no_webauthn_devices: + successful_verification: + title: + close_browser: + failed_verification: + title: + close_browser: + owners: + confirm: + confirmed_email: + token_expired: + index: + add_owner: + name: + mfa: + status: + confirmed_at: + added_by: + action: + email_field: + submit_button: + info: + confirmed: + pending: + confirm_remove: + role: + role_field: + resend_confirmation: + resent_notice: + create: + success_notice: + destroy: + removed_notice: + failed_notice: + mfa_required: + update: + success_notice: + update_current_user_role: + edit: + role: + title: settings: edit: title: + webauthn_credentials: + no_webauthn_credentials: + webauthn_credential_note: + otp_code: api_access: confirm_reset: Sind Sie sicher? Das kann nicht rückgängig gemacht werden. - credentials_html: 'Wenn du %{gem_commands_link} über das Terminal oder über die Kommandozeile verwenden möchtest, dann brauchst du die %{gem_credentials_file}-Datei, die du mit dem folgenden Befehl erzeugen kannst:' + credentials_html: 'Wenn du %{gem_commands_link} über das Terminal oder über + die Kommandozeile verwenden möchtest, dann brauchst du die %{gem_credentials_file}-Datei, + die du mit dem folgenden Befehl erzeugen kannst:' key_is_html: Dein API-Schlüssel ist %{key}. link_text: Gem-Befehle reset: Zurücksetzen meines API-Schlüssels + reset_all: title: API-Zugang reset_password: title: Zurücksetzen des Passworts mfa: multifactor_auth: - disabled: + otp: + disabled_html: go_settings: - enabled: + level_html: + enabled_note: update: + disable: + enabled: + disabled: level: title: disabled: @@ -308,12 +699,19 @@ de: ui_and_api: ui_and_gem_signin: profiles: + adoptions: + no_ownership_calls: + no_ownership_requests: + title: + subtitle_html: edit: change_avatar: + disabled_avatar_html: email_awaiting_confirmation: enter_password: - hide_email: Verberge E-Mails in öffentlichem Profil + optional_full_name: optional_twitter_username: + twitter_username: Benutzername title: Bearbeite Profil delete: delete: @@ -333,16 +731,21 @@ de: update: confirmation_mail_sent: updated: + public_email: E-Mails im öffentlichem Profil anzeigen request_denied: + show: + title: Profil von %{username} + security_events: + title: + description_html: rubygems: aside: - bundler_header: Gemfile - copied: Kopiert! - copy_to_clipboard: In die Zwischenablage kopieren downloads_for_this_version: Für diese Version - install: installieren + gem_version_age: Version veröffentlicht required_ruby_version: Erforderliche Ruby-Version required_rubygems_version: + requires_mfa: + released_with_mfa: links: badge: Abzeichen bugs: Bug Tracker @@ -350,6 +753,7 @@ de: code: Quellcode docs: Dokumentation download: Download + funding: header: Links home: Homepage mail: Mailingliste @@ -360,14 +764,20 @@ de: subscribe: Abonniere unsubscribe: Storniere wiki: Wiki - ownership: resend_ownership_confirmation: - blacklisted: - blacklisted_namespace: + ownership: + oidc: + api_key_role: + name: + new: + trusted_publishers: + reserved: + reserved_namespace: dependencies: header: "%{title} Abhängigkeiten" gem_members: authors_header: Autoren + self_no_mfa_warning_html: not_using_mfa_warning_show: not_using_mfa_warning_hide: owners_header: Besitzer @@ -375,10 +785,17 @@ de: using_mfa_info: yanked_by: sha_256_checksum: SHA 256-Prüfsumme + signature_period: + expired: + version_navigation: + previous_version: + next_version: index: downloads: Downloads title: Gems show: + bundler_header: Gemfile + install: installieren licenses_header: one: Lizenz other: Lizenzen @@ -392,10 +809,14 @@ de: reserved_namespace_html: one: other: + security_events: + title: + description_html: reverse_dependencies: index: title: subtitle: + no_reverse_dependencies: search: search_reverse_dependencies_html: searches: @@ -405,8 +826,9 @@ de: description: downloads: updated: + yanked: show: - subtitle: für %{query} + subtitle_html: für %{query} month_update: week_update: filter: @@ -420,6 +842,8 @@ de: title: confirm: notice: + create: + account_blocked: stats: index: title: @@ -436,46 +860,214 @@ de: index: not_hosted_notice: Dieses Gem wird nicht gerade von RubyGems.org gehostet. title: Alle Versionen von %{name} - versions_since: "%{count} Versionen seit %{since}" + versions_since: + other: "%{count} Versionen seit %{since}" + one: "%{count} Version seit %{since}" + imported_gem_version_notice: version: yanked: - will_paginate: - next_label: Nächstes - page_gap: "…" - previous_label: Zurück - page_entries_info: - multi_page: Zeige %{model} %{from} - %{to} von %{count} insgesamt - multi_page_html: Zeige %{model} %{from} - %{to} von %{count} insgesamt - single_page: - one: Zeige 1 %{model} - other: Zeige alle %{count} %{model} - zero: Kein %{model} gefunden - single_page_html: - one: Zeige 1 %{model} - other: Zeige alle %{count} %{model} - zero: Kein %{model} gefunden - owners: - confirm: - confirmed_email: - token_expired: + adoptions: index: - add_owner: - name: - mfa: - status: - confirmed_at: - added_by: - action: - info: - email_field: - submit_button: - confirmed: - pending: - confirm_remove: - resend_confirmation: - resent_notice: + title: + subtitle_owner_html: + subtitle_user_html: + ownership_calls: + no_ownership_calls: + ownership_calls: + update: + success_notice: create: success_notice: - destroy: - removed_notice: - failed_notice: + index: + title: + subtitle_html: + share_requirements: + note_for_applicants: + created_by: + details: + apply: + close: + markup_supported_html: + create_call: + ownership_requests: + create: + success_notice: + update: + approved_notice: + closed_notice: + close: + success_notice: + ownership_requests: + note_for_owners: + your_ownership_requests: + close_all: + approve: + gems_published: + created_at: + no_ownership_requests: + create_req: + signin_to_create_html: + webauthn_credentials: + callback: + success: + recovery: + continue: + title: + notice_html: + saved: + webauthn_credential: + confirm_delete: + delete_failed: + delete: + confirm: + saved: + form: + new_device: + nickname: + submit: + oidc: + api_key_roles: + index: + api_key_roles: + new_role: + show: + api_key_role_name: + automate_gh_actions_publishing: + view_provider: + edit_role: + delete_role: + confirm_delete: + deleted_at_html: + edit: + edit_role: + git_hub_actions_workflow: + title: + configured_for_html: + to_automate_html: + not_github: + not_push: + a_gem: + instructions_html: + new: + title: + update: + success: + create: + success: + destroy: + success: + form: + add_condition: + remove_condition: + add_statement: + remove_statement: + deleted: + providers: + index: + title: + description_html: + show: + title: + id_tokens: + index: + title: + show: + title: + rubygem_trusted_publishers: + index: + title: + subtitle_owner_html: + delete: + create: + description_html: + destroy: + success: + create: + success: + new: + title: + subtitle_owner_html: + pending_trusted_publishers: + index: + title: + valid_for_html: + delete: + create: + description_html: + destroy: + success: + create: + success: + new: + title: + trusted_publisher: + unsupported_type: + github_actions: + repository_owner_help_html: + repository_name_help_html: + workflow_filename_help_html: + environment_help_html: + pending: + rubygem_name_help_html: + duration: + minutes: + other: + one: + hours: + other: + one: + days: + other: + one: + seconds: + other: + one: + form: + optional: + events: + table_component: + event: + time: + additional_info: + redacted: + no_user_agent_info: + rubygem_event: + version: + version_pushed: + version_yanked: + version_unyanked: + version_html: + version_pushed_sha256_html: + version_pushed_by_html: + version_yanked_by_html: + owner: + owner_added: + owner_added_owner_html: + owner_added_authorizer_html: + owner_removed: + owner_removed_owner_html: + owner_removed_by_html: + owner_confirmed: + user_event: + user: + created: + email: + login: + login_success: + webauthn_login: + mfa_method: + mfa_device: + none: + email: + email_verified: + email_sent_subject: + email_sent_from: + email_sent_to: + api_key: + api_key_created: + api_key_deleted: + api_key_name: + api_key_scopes: + api_key_gem_html: + api_key_mfa: + not_required: \ No newline at end of file diff --git a/config/locales/en.yml b/config/locales/en.yml index 66e0ff175e8..83681e23319 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,25 +1,36 @@ +--- en: + credentials_required: Credentials required + copied: Copied! + copy_to_clipboard: Copy to clipboard edit: Edit - failure_when_forbidden: Please double check the URL or try submitting it again. + verification_expired: The verification has expired. Please verify again. feed_latest: RubyGems.org | Latest Gems feed_subscribed: RubyGems.org | Subscribed Gems - footer_about_html: RubyGems.org is the Ruby community’s gem hosting service. + footer_about_html: + RubyGems.org is the Ruby community’s gem hosting service. Instantly publish your gems and then install them. Use the API to find out more about available gems. Become a contributor and improve the site yourself. - footer_sponsors_html: RubyGems.org is made possible through a partnership with the greater Ruby community. + footer_sponsors_html: + RubyGems.org is made possible through a partnership with the greater Ruby community. Fastly provides bandwidth and CDN support, Ruby Central covers infrastructure costs, and - Ruby Together funds ongoing development and ops work. + funds ongoing development and ops work. Learn more about our sponsors and how they work together. - footer_join_rt_html: We need your help to fund the developer time that keeps RubyGems.org running smoothly for everyone. - Join Ruby Together today. + footer_join_rt_html: + We need your help to fund the developer time that keeps RubyGems.org running smoothly for everyone. + Join Ruby Central today. form_disable_with: Please wait... invalid_page: Page number is out of range. Redirected to default page. locale_name: English none: None not_found: Not Found + forbidden: Forbidden + api_gem_not_found: This gem could not be found api_key_forbidden: The API key doesn't have access + api_key_soft_deleted: An invalid API key cannot be used. Please delete it and create a new one. + api_key_insufficient_scope: This API key cannot perform the specified action on this gem. please_sign_up: Access Denied. Please sign up for an account at https://rubygems.org please_sign_in: Please sign in to continue. otp_incorrect: Your OTP code is incorrect. Please check it and retry. @@ -27,7 +38,7 @@ en: sign_in: Sign in sign_up: Sign up dependency_list: Show all transitive dependencies - multifactor_authentication: Multifactor authentication + multifactor_authentication: Multi-factor authentication subtitle: your community gem host this_rubygem_could_not_be_found: This rubygem could not be found. time_ago: "%{duration} ago" @@ -35,6 +46,11 @@ en: update: Update try_again: Something went wrong. Please try again. advanced_search: Advanced Search + authenticate: Authenticate + helpers: + submit: + create: "Create %{model}" + update: "Update %{model}" activerecord: attributes: linkset: @@ -51,21 +67,102 @@ en: user: avatar: Avatar email: Email address + full_name: Full name handle: Username password: Password + ownership/role: + owner: Owner + admin: Admin + maintainer: Maintainer + api_key: + oidc_api_key_role: OIDC API Key Role + oidc/id_token: + jti: JWT ID + api_key_role: API Key Role + oidc/api_key_role: + api_key_permissions: API Key Permissions + oidc/trusted_publisher/github_action: + repository_owner_id: GitHub Repository Owner ID + oidc/pending_trusted_publisher: + rubygem_name: RubyGem name errors: messages: unpwn: has previously appeared in a data breach and should not be used blocked: "domain '%{domain}' has been blocked for spamming. Please use a valid personal email." + models: + api_key: + attributes: + expires_at: + inclusion: "must be in the future" + organization: + attributes: + handle: + invalid: "must start with a letter and can only contain letters, numbers, underscores, and dashes" + ownership: + attributes: + user_id: + already_confirmed: "is already an owner of this gem" + already_invited: "is already invited to this gem" + ownership_request: + attributes: + user_id: + taken: "has already requested ownership" + existing: "is already an owner" + user: + attributes: + handle: + invalid: "must start with a letter and can only contain letters, numbers, underscores, and dashes" + version: + attributes: + gem_full_name: + taken: "%{value} already exists" + full_name: + taken: "%{value} already exists" + oidc/rubygem_trusted_publisher: + attributes: + rubygem: + taken: "has already been configured with this trusted publisher" + oidc/pending_trusted_publisher: + attributes: + rubygem_name: + unavailable: "is already in use" + models: + user: User + api_key: + zero: API Keys + one: API Key + other: API Keys + activemodel: + attributes: + oidc/provider/configuration: + jwks_uri: JWKS URI + id_token_signing_alg_values_supported: ID Token signing algorithms supported + errors: + models: + oidc/api_key_permissions: + attributes: + valid_for: + inclusion: "%{value} seconds must be between 5 minutes (300 seconds) and 1 day (86,400 seconds)" + gems: + too_long: "may include at most 1 gem" api_keys: + form: + exclusive_scopes: Exclusive Scopes + rubygem_scope: Gem Scope + rubygem_scope_info: This scope restricts gem push/yank and owner add/remove commands to a specific gem. + multifactor_auth: Multi-factor authentication + enable_mfa: Enable MFA + expiration: Expiration create: success: "Created new API key" + invalid_gem: Selected gem cannot be scoped to this key destroy: success: "Successfully deleted API key: %{name}" index: api_keys: API keys name: Name scopes: Scopes + gem: Gem age: Age last_access: Last access action: Action @@ -80,20 +177,24 @@ en: remove_owner: Remove owner access_webhooks: Access webhooks show_dashboard: Show dashboard + configure_trusted_publishers: Configure trusted publishers reset: Reset save_key: "Note that we won't be able to show the key to you again. New API key:" + mfa: MFA + expiration: Expiration + update_owner: Update Owner new: new_api_key: New API key reset: success: "Deleted all API keys" update: success: "Successfully updated API key" + invalid_gem: Selected gem cannot be scoped to this key edit: edit_api_key: "Edit API key" - clearance_mailer: - change_password: - title: CHANGE PASSWORD - subtitle: Hi %{handle}! + invalid_key: An invalid API key cannot be edited. Please delete it and create a new one. + all_gems: All Gems + gem_ownership_removed: Ownership of %{rubygem_name} has been removed after being scoped to this key. dashboards: show: creating_link_text: creating @@ -106,6 +207,9 @@ en: no_owned_html: You haven't pushed any gems yet. Perhaps check out the guides on %{creating_link} a gem or %{migrating_link} a gem from RubyForge. no_subscriptions_html: You're not subscribed to any gems yet. Visit a %{gem_link} to subscribe to one! title: Dashboard + dependencies: + show: + click_to_expand: Click on the arrow icon to expand. email_confirmations: create: promise_resend: We will email you confirmation link to activate your account if one exists. @@ -115,6 +219,7 @@ en: will_email_notice: We will email you confirmation link to activate your account. update: confirmed_email: Your email address has been verified. + token_failure: Please double check the URL or try submitting it again. home: index: downloads_counting_html: downloads & counting @@ -146,6 +251,9 @@ en: tested_by: Tested by tracking_by: Tracking by uptime: Uptime + verified_by: Verified by + secured_by: Secured by + looking_for_maintainers: maintainers wanted header: dashboard: Dashboard settings: Settings @@ -154,52 +262,70 @@ en: sign_in: Sign in sign_out: Sign out sign_up: Sign up + mfa_banner_html: 🎉 We now support security devices! Improve your account security by setting up a new device. [Learn more](link to blog post)! mailer: confirm_your_email: Please confirm your email address with the link sent to your email. - confirmation_subject: Please confirm your email address with RubyGems.org + confirmation_subject: Please confirm your email address with %{host} link_expiration_explanation_html: Please keep in mind that this link is valid for only 3 hours. You can request the updated link using resend confirmation mail page. email_confirmation: title: EMAIL CONFIRMATION subtitle: Almost done! confirmation_link: Confirm email address - welcome_message: Welcome to RubyGems.org! Click the link below to verify your email. + welcome_message: Welcome to %{host}! Visit the link below to verify your email. email_reset: title: EMAIL RESET subtitle: Hi %{handle}! - visit_link_instructions: You changed your email address on RubyGems.org. Please visit the following url to re-activate your account. + visit_link_instructions: You changed your email address on %{host}. Please visit the following url to re-activate your account. deletion_complete: title: DELETION COMPLETE subtitle: Bye! - subject: Your account has been deleted from rubygems.org - body_html: Your request for account deletion on rubygems.org has been processed. You can always create a new account using our %{sign_up} page. + subject: Your account has been deleted from %{host} + body_html: Your request for account deletion on %{host} has been processed. You can always create a new account using our %{sign_up} page. deletion_failed: title: DELETION FAILED subtitle: Sorry! - subject: Your account deletion request on rubygems.org has failed - body_html: You had request for account deletion on rubygems.org. Unfortunately, we were not able to process your request. Please try again after some time or %{contact} us if problem persists. + subject: Your account deletion request on %{host} has failed + body_html: You had requested account deletion on %{host}. Unfortunately, we were not able to process your request. Please try again after some time or %{contact} us if problem persists. notifiers_changed: - subject: You changed your RubyGems.org email notification settings + subject: You changed your %{host} email notification settings title: EMAIL NOTIFICATIONS subtitle: Hi %{handle} - 'on': 'ON' + "on": "ON" off_html: OFF gem_pushed: - subject: Gem %{gem} pushed to RubyGems.org + subject: Gem %{gem} pushed to %{host} title: GEM PUSHED gem_yanked: - subject: Gem %{gem} yanked from RubyGems.org + subject: Gem %{gem} yanked from %{host} title: GEM YANKED reset_api_key: - subject: RubyGems.org API key was reset + subject: "%{host} API key was reset" title: API KEY RESET subtitle: Hi %{handle} + webauthn_credential_created: + subject: New security device added on %{host} + title: SECURITY DEVICE ADDED + subtitle: Hi %{handle}! + webauthn_credential_removed: + subject: Security device removed on %{host} + title: SECURITY DEVICE REMOVED + subtitle: Hi %{handle}! + totp_enabled: + subject: Authentication app enabled on %{host} + title: AUTHENTICATION APP ENABLED + subtitle: Hi %{handle}! + totp_disabled: + subject: Authentication app disabled on %{host} + title: AUTHENTICATION APP DISABLED + subtitle: Hi %{handle}! email_reset_update: - subject: You have requested email address update on RubyGems.org + subject: You have requested email address update on %{host} title: EMAIL UPDATE REQUESTED ownership_confirmation: - subject: Please confirm the ownership of %{gem} gem on RubyGems.org + subject: Please confirm the ownership of %{gem} gem on %{host} title: OWNERSHIP CONFIRMATION subtitle: Hi %{handle}! + body_text: You were added as an owner to %{gem} gem by %{authorizer}. Please visit link below to confirm your ownership. body_html: You were added as an owner to %{gem} gem by %{authorizer}. Please click on the link below to confirm your ownership. link_expiration_explanation_html: Please note that this link is valid for only %{expiry_hours}. You can resend confirmation mail from the %{gem} gem page after sign in. owner_added: @@ -207,13 +333,63 @@ en: subject_others: User %{owner_handle} was added as an owner to %{gem} gem title: OWNER ADDED subtitle: Hi %{user_handle}! - body_self_html: You were added as an owner to %{gem} gem on RubyGems.org. + body_self_html: You were added as an owner to %{gem} gem on %{host}. body_others_html: %{owner_handle} was added as an owner to %{gem} gem by %{authorizer}. You are receiving this notification because you are an owner of %{gem}. owner_removed: subject: You were removed as an owner from %{gem} gem title: OWNER REMOVED subtitle: Hi %{user_handle}! - body_html: You were removed as an owner for %{gem} gem on RubyGems.org by %{remover}. + body_html: You were removed as an owner for %{gem} gem on %{host} by %{remover}. + owner_updated: + subject: Your role was updated for %{gem} gem + title: OWNER ROLE UPDATED + subtitle: Hi %{user_handle}! + body_html: Your role was updated to %{role} for %{gem} gem. + body_text: Your role was updated to %{role} for %{gem} gem. + ownerhip_request_closed: + title: OWNERSHIP REQUEST + subtitle: Hi %{handle}! + body_html: Thank you for applying for ownership of %{gem}. We regret to inform you that your ownership request was closed by the gem owner. + ownerhip_request_approved: + body_html: Congratulations! Your ownership request for %{gem} was approved. You were added as an owner to the gem. + new_ownership_requests: + body_html: + zero: There are no new ownership requests for %{gem}. + one: There is one new ownership request for %{gem}. Please click on the button below to see it. + other: There are %{count} new ownership requests for %{gem}. Please click on the button below to see all requests. + button: OWNERSHIP REQUESTS + disable_notifications: To stop receiving these messages, update your + owners_page: OWNERSHIPS + web_hook_deleted: + title: WEBHOOK DELETED + subject: Your %{host} webhook was deleted + subtitle: Hi %{handle}! + body_text: Your webhook that POSTed to %{url} has been deleted after %{failures} failures. + body_html: Your webhook that POSTed to %{url} has been deleted after %{failures} failures. + global_text: This webhook was previously called when any gem was pushed. + global_html: This webhook was previously called when any gem was pushed. + gem_text: This webhook was previously called when %{gem} was pushed. + gem_html: This webhook was previously called when %{gem} was pushed. + web_hook_disabled: + title: WEBHOOK DISABLED + subject: Your %{host} webhook was disabled + subtitle: Hi %{handle}! + body_text: | + Your webhook that POSTed to %{url} has been disabled due to %{disabled_reason}. + It last succeeded at %{last_success} and has failed %{failures_since_last_success} times since then. + You may delete this webhook by running `%{delete_command}`. + body_html: | +

          Your webhook that POSTed to %{url} has been disabled due to %{disabled_reason}.

          +

          It last succeeded at %{last_success} and has failed %{failures_since_last_success} times since then.

          +

          You may delete this webhook by running %{delete_command}.

          + global_text: This webhook was previously called when any gem was pushed. + global_html: This webhook was previously called when any gem was pushed. + gem_text: This webhook was previously called when %{gem} was pushed. + gem_html: This webhook was previously called when %{gem} was pushed. + gem_trusted_publisher_added: + title: TRUSTED PUBLISHER ADDED + admin_manual: + title: MESSAGE FROM RUBYGEMS.ORG ADMINS news: show: title: New Releases — All Gems @@ -223,14 +399,20 @@ en: title: New Releases — Popular Gems pages: about: + contributors_amount: "%{count} Rubyists" + downloads_amount: "millions of gem downloads" + checkout_code: "please check out the code" + mit_licensed: "MIT licensed" + logo_header: Looking for our logo? + logo_details: Just select the download button and you'll get three .PNGs and an .SVG of the RubyGems logo all for yourself. founding_html: The project was started in April 2009 by %{founder}, and has since grown to include the contributions of over %{contributors} and %{downloads}. As of the RubyGems 1.3.6 release, the site has been renamed to %{title} from Gemcutter to solidify the site's central role in the Ruby community. support_html: Although RubyGems.org is not run by one specific company, plenty have helped us out so far. The current design, illustrations, and front-end development of this site were created by %{dockyard}. %{github} has also been invaluable for helping us collaborate and share code easily. The site started on %{heroku}, whose great service helped prove RubyGems as a viable solution that the whole community could rely on. Our infrastructure is currently hosted on %{aws}. - technical_html: 'Some insights into the technical aspects of the site: It''s 100% Ruby. The main site is a %{rails} application. Gems are hosted on %{s3}, served by %{fastly}, and the time between publishing a new gem and having it ready for installation is usually just a few seconds. For more info, %{source_code}, which is %{license} over at GitHub.' + technical_html: "Some insights into the technical aspects of the site: It's 100% Ruby. The main site is a %{rails} application. Gems are hosted on %{s3}, served by %{fastly}, and the time between publishing a new gem and having it ready for installation is usually just a few seconds. For more info, %{source_code}, which is %{license} over at GitHub." title: About purpose: better_api: Provide a better API for dealing with gems enable_community: Enable the community to improve and enhance the site - header: 'Welcome to %{site}, the Ruby community''s gem hosting service. The purpose of this project is three-fold:' + header: "Welcome to %{site}, the Ruby community's gem hosting service. The purpose of this project is three-fold:" transparent_pages: Create more transparent and accessible project pages data: title: RubyGems.org Data Dumps @@ -244,23 +426,78 @@ en: title: Security sponsors: title: Sponsors + password_mailer: + change_password: + closing: If you didn't request this, ignore this email. Your password has + not been changed. + opening: "Someone, hopefully you, requested we send you a link to change + your password:" + title: CHANGE PASSWORD + subtitle: Hi %{handle}! passwords: edit: submit: Save this password title: Reset password + token_failure: Please double check the URL or try submitting a new password reset. new: submit: Reset password title: Change your password will_email_notice: We will email you a link to change your password. - otp_prompt: - authenticate: Authenticate + create: + success: You will receive an email within the next few minutes. It contains instructions for changing your password. + failure_on_missing_email: Email can't be blank. + update: + failure: Your password could not be changed. Please try again. multifactor_auths: + session_expired: Your login page session has expired. + require_mfa_enabled: Your multi-factor authentication has not been enabled. You have to enable it first. + require_webauthn_enabled: You don't have any security devices enabled. You have to associate a device to your account first. + setup_required_html: For protection of your account and your gems, you are required to set up multi-factor authentication. Please read our blog post for more details. + setup_recommended: For protection of your account and your gems, we encourage you to set up multi-factor authentication. Your account will be required to have MFA enabled in the future. + strong_mfa_level_required_html: For protection of your account and your gems, you are required to change your MFA level to "UI and gem signin" or "UI and API". Please read our blog post for more details. + strong_mfa_level_recommended: For protection of your account and your gems, we encourage you to change your MFA level to "UI and gem signin" or "UI and API". Your account will be required to have MFA enabled on one of these levels in the future. + setup_webauthn_html: 🎉 We now support security devices! Improve your account security by setting up a new device. Learn more! + api: + mfa_required: Gem requires MFA enabled; You do not have MFA enabled yet. + mfa_required_not_yet_enabled: | + [ERROR] For protection of your account and your gems, you are required to set up multi-factor authentication at https://rubygems.org/totp/new. + + Please read our blog post for more details (https://blog.rubygems.org/2022/08/15/requiring-mfa-on-popular-gems.html). + mfa_required_weak_level_enabled: | + [ERROR] For protection of your account and your gems, you are required to change your MFA level to 'UI and gem signin' or 'UI and API' at https://rubygems.org/settings/edit. + + Please read our blog post for more details (https://blog.rubygems.org/2022/08/15/requiring-mfa-on-popular-gems.html). + mfa_recommended_not_yet_enabled: | + [WARNING] For protection of your account and gems, we encourage you to set up multi-factor authentication at https://rubygems.org/totp/new. + Your account will be required to have MFA enabled in the future. + mfa_recommended_weak_level_enabled: | + [WARNING] For protection of your account and gems, we encourage you to change your multi-factor authentication level to 'UI and gem signin' or 'UI and API' at https://rubygems.org/settings/edit. + Your account will be required to have MFA enabled on one of these levels in the future. + recovery: + continue: Continue + title: Recovery codes + saved: I acknowledge that I have saved my recovery codes. + confirm_dialog: Leave without copying recovery codes? + note_html: "Please copy and save these recovery codes. You can use these codes to login and reset your MFA if your lose your authentication device. Each code can be used once." + already_generated: You should have already saved your recovery codes. + update: + invalid_level: Invalid MFA level. + success: You have successfully updated your multi-factor authentication level. + prompt: + webauthn_credential_note: Authenticate with a security device such as Touch ID, YubiKey, etc. + sign_in_with_webauthn_credential: Authenticate with security device + otp_code: OTP code + otp_or_recovery: OTP or recovery code + recovery_code: Recovery code + recovery_code_html: 'You can use a valid recovery code if you have lost access to your multi-factor authentication device or to your security device.' + security_device: Security Device + verify_code: Verify code + totps: incorrect_otp: Your OTP code is incorrect. - otp_code: OTP code - require_mfa_disabled: Your multifactor authentication has been enabled. You have to disable it first. - require_mfa_enabled: Your multifactor authentication has not been enabled. You have to enable it first. + require_totp_disabled: Your OTP based multi-factor authentication has already been enabled. To reconfigure your OTP based authentication, you'll have to remove it first. + require_totp_enabled: You don't have an authenticator app enabled. You have to enable it first. new: - title: Enabling multifactor auth + title: Enabling multi-factor auth scan_prompt: Please scan the qr-code with your authenticator app. If you cannot scan the code, add information below into your app manually. otp_prompt: Type digit code from auth app to continue. confirm: I have kept my recovery codes safe. @@ -270,30 +507,44 @@ en: time_based: "Time based: Yes" create: qrcode_expired: The QR-code and key is expired. Please try registering a new device again. - success: You have successfully enabled multifactor authentication. - recovery: - continue: Continue - title: Recovery codes - note: "You MUST keep the recovery codes safe to prevent losing your account. Each of the code can be used once if you lose your authenticator." + success: You have successfully enabled OTP based multi-factor authentication. destroy: - success: You have successfully disabled multifactor authentication. - update: - success: You have successfully updated your MFA level. + success: You have successfully disabled OTP based multi-factor authentication. notifiers: update: success: You have successfully updated your email notification settings. show: - info: To aid detection of unauthorized gem or ownership changes, we email you each time a new version of a + info: + To aid detection of unauthorized gem or ownership changes, we email you each time a new version of a gem you own is pushed, yanked or a new owner was added. By receiving and reading these emails, you help protect the Ruby ecosystem. - 'on': 'On' - 'off': 'Off' + "on": "On" + "off": "Off" recommended: recommended title: Email notifications update: Update owner_heading: Ownership Notifications + owner_request_heading: Ownership Request Notifications push_heading: Push Notifications + webauthn_verifications: + expired_or_already_used: The token in the link you used has either expired or been used already. + no_port: No port provided. Please try again. + pending: Security device authentication is still pending. + prompt: + title: Authenticate with Security Device + authenticating_as: Authenticating as + authenticate: Authenticate + no_webauthn_devices: You don't have any security devices enabled + successful_verification: + title: Success! + close_browser: Please close this browser. + failed_verification: + title: Error - Verification Failed + close_browser: Please close this browser and try again. owners: + edit: + role: Role + title: Edit Owner confirm: confirmed_email: You were added as an owner to %{gem} gem token_expired: The confirmation token has expired. Please try resending the token from the gem page. @@ -305,6 +556,8 @@ en: confirmed_at: CONFIRMED AT added_by: ADDED BY action: ACTION + role: ROLE + role_field: Role email_field: Email / Handle submit_button: Add Owner info: add or remove owners @@ -315,40 +568,63 @@ en: resent_notice: A confirmation mail has been re-sent to your email create: success_notice: "%{handle} was added as an unconfirmed owner. Ownership access will be enabled after the user clicks on the confirmation mail sent to their email." + update: + update_current_user_role: Can't update your own Role + success_notice: "%{handle} was succesfully updated." destroy: removed_notice: "%{owner_name} was removed from the owners successfully" failed_notice: Can't remove the only owner of the gem + mfa_required: The gem has MFA requirement enabled, please setup MFA on your account. settings: edit: title: Edit settings + webauthn_credentials: Security device + no_webauthn_credentials: You don't have any security devices + webauthn_credential_note: A security device can be any device that complies with the FIDO2 standard such as security and biometric keys. + otp_code: OTP code or recovery code api_access: confirm_reset: Are you sure? This cannot be undone. - credentials_html: 'If you want to use %{gem_commands_link} from the command line, you''ll need a %{gem_credentials_file} file, which you can generate using the following command:' + credentials_html: "If you want to use %{gem_commands_link} from the command line, you'll need a %{gem_credentials_file} file, which you can generate using the following command:" key_is_html: Your API key is %{key}. link_text: gem commands reset: Reset my API key + reset_all: Delete all scoped API keys title: API Access reset_password: title: Reset password mfa: - multifactor_auth: Multifactor authentication - disabled: You have not yet enabled multifactor authentication. + multifactor_auth: Multi-factor authentication + otp: Authentication app + disabled_html: You have not yet enabled OTP based multi-factor authentication. Please refer to RubyGems MFA guide for more information on MFA levels. go_settings: Register a new device - enabled: You have enabled multifactor authentication. Please input your OTP from your authenticator or one of your active recovery codes to change your MFA level or disable it. + level_html: You have enabled multi-factor authentication. Please click 'Update' to change your MFA level. Please refer to RubyGems MFA guide for more information on MFA levels. + enabled_note: + You have enabled multi-factor authentication. Please input your OTP from your authenticator or one of your active + recovery codes to disable it. update: Update + disable: Disable + enabled: Enabled + disabled: Disabled level: title: MFA Level disabled: Disabled ui_only: UI Only - ui_and_api: UI and API + ui_and_api: UI and API (Recommended) ui_and_gem_signin: UI and gem signin profiles: + adoptions: + no_ownership_calls: You have not created any ownership call for any of your gems. + no_ownership_requests: You have not created any ownership request. + title: Adoption + subtitle_html: Ask for new maintainers or request ownership (read more) edit: change_avatar: Change Avatar + disabled_avatar_html: "A default avatar is used due to private email settings. To enable a personalized Gravatar, turn on 'Show email in public profile'. Notice this will expose your email to the public." email_awaiting_confirmation: Please confirm your new email address %{unconfirmed_email} enter_password: Please enter your account's password - hide_email: Hide email in public profile - optional_twitter_username: Optional Twitter username. Will be displayed publicly + optional_full_name: Optional. Will be displayed publicly + optional_twitter_username: Optional X username. Will be displayed publicly + twitter_username: Username title: Edit profile delete: delete: Delete @@ -368,16 +644,21 @@ en: update: confirmation_mail_sent: You will receive an email within the next few minutes. It contains instructions for confirming your new email address. updated: Your profile was updated. + public_email: Show email in public profile request_denied: This request was denied. We could not verify your password. + show: + title: Profile of %{username} + security_events: + title: Security Events + description_html: "This page shows the security events that have occurred on your account. If you see any suspicious activity, please contact support." rubygems: aside: - bundler_header: Gemfile - copied: Copied! - copy_to_clipboard: Copy to clipboard downloads_for_this_version: For this version - install: install + gem_version_age: Version Released required_ruby_version: Required Ruby Version required_rubygems_version: Required Rubygems Version + requires_mfa: New versions require MFA + released_with_mfa: Version published with MFA links: badge: Badge bugs: Bug Tracker @@ -385,6 +666,7 @@ en: code: Source Code docs: Documentation download: Download + funding: Funding header: Links home: Homepage mail: Mailing List @@ -397,23 +679,36 @@ en: wiki: Wiki resend_ownership_confirmation: Resend confirmation ownership: Ownership - blacklisted: - blacklisted_namespace: This namespace is reserved by rubygems.org. + oidc: + api_key_role: + name: "OIDC: %{name}" + new: "OIDC: Create" + trusted_publishers: Trusted publishers + reserved: + reserved_namespace: This namespace is reserved by rubygems.org. dependencies: header: "%{title} Dependencies" gem_members: authors_header: Authors - not_using_mfa_warning_show: "* Some owners are not using MFA. Click for the complete list." - not_using_mfa_warning_hide: "* The following owners are not using MFA. Click to hide." + self_no_mfa_warning_html: Please consider enabling multi-factor authentication (MFA) to keep your account secure. + not_using_mfa_warning_show: "* Some owners are not using multi-factor authentication (MFA). Click for the complete list." + not_using_mfa_warning_hide: "* The following owners are not using multi-factor authentication (MFA). Click to hide." owners_header: Owners pushed_by: Pushed by - using_mfa_info: "* All owners are using MFA." + using_mfa_info: "* All owners are using multi-factor authentication (MFA)." yanked_by: Yanked by sha_256_checksum: SHA 256 checksum + signature_period: Signature validity period + expired: expired + version_navigation: + previous_version: ← Previous version + next_version: Next version → index: downloads: Downloads title: Gems show: + bundler_header: Gemfile + install: install licenses_header: one: License other: Licenses @@ -427,10 +722,14 @@ en: reserved_namespace_html: one: This gem previously existed, but has been removed by its owner. The RubyGems.org team has reserved this gem name for 1 more day. After that time is up, anyone will be able to claim this gem name using gem push.
          If you are the previous owner of this gem, you can change ownership of this gem using the gem owner command. You can also create new versions of this gem using gem push. other: This gem previously existed, but has been removed by its owner. The RubyGems.org team has reserved this gem name for %{count} more days. After that time is up, anyone will be able to claim this gem name using gem push.
          If you are the previous owner of this gem, you can change ownership of this gem using the gem owner command. You can also create new versions of this gem using gem push. + security_events: + title: Security Events + description_html: "This page shows the security events that have occurred on %{gem}. If you see any suspicious activity, please contact support." reverse_dependencies: index: title: "Reverse dependencies for %{name}" subtitle: "Latest version of the following gems require %{name}" + no_reverse_dependencies: This gem has no reverse dependencies. search: search_reverse_dependencies_html: "Search reverse dependencies Gems…" searches: @@ -440,8 +739,9 @@ en: description: Description downloads: Downloads updated: Updated + yanked: Yanked show: - subtitle: for %{query} + subtitle_html: for %{query} month_update: Updated last month (%{count}) week_update: Updated last week (%{count}) filter: "Filter:" @@ -455,6 +755,8 @@ en: title: Confirm Password confirm: Confirm notice: Please confirm your password to continue. + create: + account_blocked: Your account was blocked by rubygems team. Please email support@rubygems.org to recover your account. stats: index: title: Stats @@ -471,21 +773,222 @@ en: index: not_hosted_notice: This gem is not currently hosted on RubyGems.org. title: All versions of %{name} - versions_since: "%{count} versions since %{since}" + versions_since: + other: "%{count} versions since %{since}" + one: "1 version since %{since}" + imported_gem_version_notice: "This gem version was imported to RubyGems.org on %{import_date}. The date displayed was specified by the author in the gemspec." version: yanked: yanked - will_paginate: - next_label: Next - page_gap: "…" - previous_label: Previous - page_entries_info: - multi_page: Displaying %{model} %{from} - %{to} of %{count} in total - multi_page_html: Displaying %{model} %{from} - %{to} of %{count} in total - single_page: - one: Displaying 1 %{model} - other: Displaying all %{count} %{model} - zero: No %{model} found - single_page_html: - one: Displaying 1 %{model} - other: Displaying all %{count} %{model} - zero: No %{model} found + adoptions: + index: + title: Adoptions + subtitle_owner_html: Ask new maintainers to join %{gem} (read more) + subtitle_user_html: Request ownership of %{gem} (read more) + ownership_calls: Ownership Call + no_ownership_calls: There are no ownership calls for %{gem}. The gem owners are not looking for new maintainers. + ownership_calls: + update: + success_notice: The ownership call for %{gem} was closed. + create: + success_notice: Created ownership call for %{gem}. + index: + title: Maintainers wanted + subtitle_html: RubyGems looking for new maintainers to join (read more) + share_requirements: Please share in what areas do you need help + note_for_applicants: "Note for applicants:" + created_by: Created by + details: Details + apply: Apply + close: Close + markup_supported_html: Rdoc markup supported + create_call: Create ownership call + ownership_requests: + create: + success_notice: Your ownership request was submitted. + update: + approved_notice: Ownership request was approved. %{name} is added as an owner. + closed_notice: Ownership request was closed. + close: + success_notice: All open ownership requests for %{gem} were closed. + ownership_requests: Ownership Requests + note_for_owners: "Note for owners:" + your_ownership_requests: Your ownership requests + close_all: Close all + approve: Approve + gems_published: Gems published + created_at: Created at + no_ownership_requests: Requests to join your project will show up here. No ownership requests for %{gem} yet. + create_req: Create ownership request + signin_to_create_html: Please sign in to create an ownership request. + webauthn_credentials: + callback: + success: You have successfully registered a security device. + recovery: + continue: Continue + title: You have successfully added a security device + notice_html: 'Please copy and paste these recovery codes. You can use these codes to login if you lose your security device. Each code can be used once.' + saved: I acknowledge that I have saved my recovery codes. + webauthn_credential: + confirm_delete: "Credential deleted" + delete_failed: "Could not delete credential" + delete: Delete + confirm: Are you sure you want to delete this credential? + saved: Security device succesfully created + form: + new_device: Register a new security device + nickname: Nickname + submit: Register device + oidc: + api_key_roles: + index: + api_key_roles: OIDC API Key Roles + new_role: Create API Key Role + show: + api_key_role_name: "API Key Role %{name}" + automate_gh_actions_publishing: "Automate Gem Publishing with GitHub Actions" + view_provider: "View provider %{issuer}" + edit_role: "Edit API Key Role" + delete_role: "Delete API Key Role" + confirm_delete: "Are you sure you want to delete this role?" + deleted_at_html: "This role was deleted %{time_html} ago and can no longer be used." + edit: + edit_role: "Edit API Key Role" + git_hub_actions_workflow: + title: "OIDC Gem Push GitHub Actions Workflow" + configured_for_html: "This OIDC API Key Role is configured to allow pushing %{link_html} from GitHub Actions." + to_automate_html: "To automate releasing %{link_html} when a new tag is pushed, add the following workflow to your repository." + not_github: "This OIDC API Key Role is not configured for GitHub Actions." + not_push: "This OIDC API Key Role is not configured to allow pushing gems." + a_gem: a gem + instructions_html: | + To release, bump the gem version and push a new tag (using rake release:source_control_push) to GitHub. The workflow will automatically build the gem and push it to RubyGems.org. + new: + title: "New OIDC API Key Role" + update: + success: "OIDC API Key Role updated" + create: + success: "OIDC API Key Role created" + destroy: + success: "OIDC API Key Role deleted" + form: + add_condition: Add condition + remove_condition: Remove condition + add_statement: Add statement + remove_statement: Remove statement + deleted: "The role has been deleted." + providers: + index: + title: "OIDC Providers" + description_html: "These are the OIDC providers that have been configured for RubyGems.org.
          Please reach out to support if you need another OIDC Provider added." + show: + title: "OIDC Provider" + id_tokens: + index: + title: "OIDC ID Tokens" + show: + title: "OIDC ID Token" + rubygem_trusted_publishers: + index: + title: Trusted Publishers + subtitle_owner_html: "Trusted publishers for %{gem_html}" + delete: Delete + create: Create + description_html: | + Trusted publishers allow you to push gems from CI without storing any long-lived sensitive credentials. + For more information about how to set up trusted publishing, see the trusted publishing documentation. + destroy: + success: "Trusted Publisher deleted" + create: + success: "Trusted Publisher created" + new: + title: "New Trusted Publisher" + subtitle_owner_html: "Add a trusted publisher for %{gem_html}" + pending_trusted_publishers: + index: + title: Pending Trusted Publishers + valid_for_html: "Valid for %{time_html}" + delete: Delete + create: Create + description_html: | + Pending trusted publishers allow you to configure trusted publishing before you have pushed the first version of a gem. + For more information about how to set up trusted publishing, see the trusted publishing documentation. + destroy: + success: "Pending Trusted Publisher deleted" + create: + success: "Pending Trusted Publisher created" + new: + title: "New Pending Trusted Publisher" + trusted_publisher: + unsupported_type: "Unsupported trusted publisher type" + github_actions: + repository_owner_help_html: "The GitHub organization name or GitHub username that owns the repository" + repository_name_help_html: "The name of the GitHub repository that contains the publishing workflow" + workflow_filename_help_html: "The filename of the publishing workflow.
          This file should exist in the .github/workflows/ directory in the repository configured above." + environment_help_html: | + The name of the GitHub Actions environment that the above workflow uses for publishing.
          + This should be configured under the repository's settings.
          + While not required, a dedicated publishing environment is strongly encouraged, especially if your repository has maintainers with commit access who shouldn't have RubyGems.org gem push access. + pending: + rubygem_name_help_html: "The gem (on RubyGems.org) that will be created when this publisher is used" + duration: + minutes: + other: "%{count} minutes" + one: "1 minute" + hours: + other: "%{count} hours" + one: "1 hour" + days: + other: "%{count} days" + one: "1 day" + seconds: + other: "%{count} seconds" + one: "1 second" + form: + optional: optional + events: + table_component: + event: Event + time: Time + additional_info: Additional Info + redacted: Redacted + no_user_agent_info: No user agent info + rubygem_event: + version: + version_pushed: Version Pushed + version_yanked: Version Yanked + version_unyanked: Version Unyanked + version_html: "Version: %{version}" + version_pushed_sha256_html: "SHA256: %{sha256}" + version_pushed_by_html: "Pushed by: %{pusher}" + version_yanked_by_html: "Yanked by: %{pusher}" + owner: + owner_added: "Owner Added" + owner_added_owner_html: "New owner added: %{owner}" + owner_added_authorizer_html: "Authorized by: %{authorizer}" + owner_removed: "Owner Removed" + owner_removed_owner_html: "Owner removed: %{owner}" + owner_removed_by_html: "Removed by: %{remover}" + owner_confirmed: "Owner Confirmed" + user_event: + user: + created: "User Created" + email: "Email: %{email}" + login: + login_success: "Login Success" + webauthn_login: "Webauthn login: %{device}" + mfa_method: "MFA Method: %{method}" + mfa_device: "MFA Device: %{device}" + none: None + email: + email_verified: "Email Verified" + email_sent_subject: "Subject: %{subject}" + email_sent_from: "From: %{from}" + email_sent_to: "To: %{to}" + api_key: + api_key_created: "API Key Created" + api_key_deleted: "API Key Deleted" + api_key_name: "Name: %{name}" + api_key_scopes: "Scopes: %{scopes}" + api_key_gem_html: "Gem: %{gem}" + api_key_mfa: "MFA: %{mfa}" + not_required: "Not required" \ No newline at end of file diff --git a/config/locales/es.yml b/config/locales/es.yml index 8110a75e57b..fa9b7be388f 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -1,40 +1,57 @@ +--- es: + credentials_required: Credenciales requeridas + copied: "¡Copiado!" + copy_to_clipboard: Copiar al portapapeles edit: Editar - failure_when_forbidden: Por favor verifica la URL o inténtalo nuevamente. + verification_expired: feed_latest: RubyGems.org | Gemas más recientes feed_subscribed: RubyGems.org | Suscripciones a gemas - footer_about_html: RubyGems.org es el servicio de alojamiento de Gemas de la comunidad de Ruby. - Publica tus gemas instantáneamente y luego instálalas. - Usa la API para saber más sobre las gemas disponibles. - Conviértete en colaborador y mejora este sitio con tus cambios. - footer_sponsors_html: RubyGems.org es posible gracias la colaboración de la fantástica comunidad de Ruby. - Fastly provee ancho de banda y soporte de CDN - Ruby Central cubre los costos de infraestructura y - Ruby Together financia el desarrollo y el trabajo en los servidores. - Aprende más sobre nuestros sponsors y cómo trabajan en conjunto - footer_join_rt_html: Necesitamos tu ayuda para financiar el tiempo de los desarrolladores que mantienen RubyGems.org funcionando correctamente para todo el mundo. - Únete a Ruby Together hoy. + footer_about_html: RubyGems.org es el servicio de alojamiento de Gemas de la comunidad + de Ruby. Publica tus gemas instantáneamente y luego + instálalas. Usa la API + para saber más sobre las gemas disponibles. Conviértete + en colaborador y mejora este sitio con tus cambios. + footer_sponsors_html: RubyGems.org es posible gracias la colaboración de la fantástica + comunidad de Ruby. Fastly provee ancho de + banda y soporte de CDN Ruby Central + cubre los costos de infraestructura y financia el desarrollo y el trabajo en los + servidores. Aprende más sobre nuestros sponsors y cómo + trabajan en conjunto + footer_join_rt_html: Necesitamos tu ayuda para financiar el tiempo de los desarrolladores + que mantienen RubyGems.org funcionando correctamente para todo el mundo. Únete + a Ruby Central hoy. form_disable_with: Un momento por favor... invalid_page: Número de página inexistente. Redireccionando a la página por defecto. locale_name: Español none: Ninguno not_found: No encontrado - api_key_forbidden: + forbidden: + api_gem_not_found: + api_key_forbidden: La clave API no tiene acceso + api_key_soft_deleted: + api_key_insufficient_scope: please_sign_up: Acceso denegado. Por favor regístrate en https://rubygems.org please_sign_in: Por favor, ingresa en tu cuenta para continuar. otp_incorrect: Tu código OTP es incorrecto. Por favor verifícalo y prueba nuevamente. - otp_missing: Activaste autenticación con múltiples factores, pero no suministraste un código OTP. Por favor ingrésalo y prueba nuevamente. + otp_missing: Activaste autenticación con múltiples factores, pero no suministraste + un código OTP. Por favor ingrésalo y prueba nuevamente. sign_in: Ingresa sign_up: Regístrate - dependency_list: + dependency_list: Mostrar toda la cadena de dependencias multifactor_authentication: Autenticación de múltiples factores subtitle: el alojamiento de gemas de tu comunidad this_rubygem_could_not_be_found: Esta gema no pudo encontrarse. - time_ago: "hace %{duration}" + time_ago: hace %{duration} title: RubyGems.org - update: actualiza + update: Actualizar try_again: Algo salió mal. Por favor inténtalo nuevamente. advanced_search: Búsqueda avanzada + authenticate: Autenticar + helpers: + submit: + create: + update: activerecord: attributes: linkset: @@ -44,56 +61,149 @@ es: docs: URL de la documentación mail: URL de la lista de correo wiki: URL de la Wiki - funding: + funding: URL de financiación session: password: Contraseña who: Correo electrónico o usuario user: avatar: Avatar email: Dirección de correo + full_name: Nombre completo handle: Usuario password: Contraseña + ownership/role: + owner: + admin: + maintainer: + api_key: + oidc_api_key_role: + oidc/id_token: + jti: + api_key_role: + oidc/api_key_role: + api_key_permissions: + oidc/trusted_publisher/github_action: + repository_owner_id: + oidc/pending_trusted_publisher: + rubygem_name: errors: messages: - unpwn: - blocked: + unpwn: ha aparecido con anterioridad en una filtración de datos y no se debería + usar + blocked: El dominio '%{domain}' ha sido bloqueado por spam. Por favor utiliza + un email personal válido. + models: + api_key: + attributes: + expires_at: + inclusion: + organization: + attributes: + handle: + invalid: + ownership: + attributes: + user_id: + already_confirmed: ya es propietario de esta gema + already_invited: ya ha sido invitado a esta gema + ownership_request: + attributes: + user_id: + taken: + existing: + user: + attributes: + handle: + invalid: + version: + attributes: + gem_full_name: + taken: "%{value} ya existe" + full_name: + taken: "%{value} ya existe" + oidc/rubygem_trusted_publisher: + attributes: + rubygem: + taken: + oidc/pending_trusted_publisher: + attributes: + rubygem_name: + unavailable: + models: + user: Usuario + api_key: + zero: + one: + other: + activemodel: + attributes: + oidc/provider/configuration: + jwks_uri: + id_token_signing_alg_values_supported: + errors: + models: + oidc/api_key_permissions: + attributes: + valid_for: + inclusion: "%{value} segundos debe estar entre 5 minutos (300 segundos) + y 1 día (86.400 segundos)" + gems: + too_long: como mucho puede incluir una gema api_keys: + form: + exclusive_scopes: + rubygem_scope: Ámbito de aplicación + rubygem_scope_info: Este alcance restringe los comandos de añadir/eliminar gemas + y añadir/eliminar propietarios a una gema específica. + multifactor_auth: Autenticación multifactor + enable_mfa: Activar AMF + expiration: create: - success: + success: Nueva clave de API creada + invalid_gem: La gema seleccionada no se puede incluir en esta clave destroy: - success: + success: 'Clave de API eliminada con éxito: %{name}' index: - api_keys: - name: - scopes: - age: - last_access: - action: - delete: - confirm: - confirm_all: - new_key: - index_rubygems: - push_rubygem: - yank_rubygem: - add_owner: - remove_owner: - access_webhooks: - show_dashboard: - reset: - save_key: + api_keys: Claves de API + name: Nombre + scopes: Alcances + gem: Gema + age: Edad + last_access: Último accesso + action: Acción + delete: Borrar + confirm: Se va a invalidar la clave de API. ¿Estás seguro? + confirm_all: Todas las claves de API serán invalidadas. ¿Estás seguro? + new_key: Nueva clave de API + index_rubygems: Índice de rubygems + push_rubygem: Añadir a rubygems + yank_rubygem: Eliminar de rubygems + add_owner: Añadir propietario + remove_owner: Eliminar propietario + access_webhooks: Acceso a webhooks + show_dashboard: Mostrar dashboard + configure_trusted_publishers: + reset: Restablecer + save_key: 'Ten en cuenta que no se volverá a mostrar la clave de API. Nueva + clave de API:' + mfa: AMF + expiration: + update_owner: new: - new_api_key: + new_api_key: Nueva clave de API reset: - success: + success: Borradas todas las claves de API update: - success: + success: Clave de API actualizada con éxito + invalid_gem: La gema seleccionada no se puede incluir en el alcance de esta + clave edit: - edit_api_key: - clearance_mailer: - change_password: - title: CAMBIAR CONTRASEÑA - subtitle: ¡Hola %{handle}! + edit_api_key: Editar clave de API + invalid_key: No se puede editar una clave de API inválida. Por favor bórrala + y crea una nueva. + all_gems: Todas las gemas + gem_ownership_removed: Se ha eliminado la propiedad de %{rubygem_name} tras haber + sido añadida a esta clave. dashboards: show: creating_link_text: crear @@ -103,18 +213,26 @@ es: migrating_link_text: migrar mine: Mis Gemas my_subscriptions: Suscripciones - no_owned_html: Todavía no publicaste ninguna gema. Prueba a ver las guías sobre cómo %{creating_link} o %{migrating_link} una gema en rubygems.org. - no_subscriptions_html: Aún no te suscribiste a ninguna gema. ¡Visita la %{gem_link} para suscribirte! + no_owned_html: Todavía no publicaste ninguna gema. Prueba a ver las guías sobre + cómo %{creating_link} o %{migrating_link} una gema en rubygems.org. + no_subscriptions_html: Aún no te suscribiste a ninguna gema. ¡Visita la %{gem_link} + para suscribirte! title: Dashboard + dependencies: + show: + click_to_expand: Haz clic en el icono de flecha para expandir email_confirmations: create: - promise_resend: Te enviaremos un correo con el enlace de confirmación para activar tu cuenta si esta existe. + promise_resend: Te enviaremos un correo con el enlace de confirmación para activar + tu cuenta si esta existe. new: submit: Re-enviar Confirmación title: Re-enviar correo de confirmación - will_email_notice: Te enviaremos un correo con el enlace de confirmación para activar tu cuenta. + will_email_notice: Te enviaremos un correo con el enlace de confirmación para + activar tu cuenta. update: confirmed_email: Tu dirección de correo se ha verificado. + token_failure: Por favor verifica la URL o inténtalo nuevamente. home: index: downloads_counting_html: descargas y contando @@ -146,74 +264,173 @@ es: tested_by: Probado por tracking_by: Seguimiento con uptime: Uptime + verified_by: Verificado por + secured_by: Protegido por + looking_for_maintainers: Se buscan mantenedores/as header: dashboard: Dashboard - edit_profile: settings: Configuración + edit_profile: Editar perfil search_gem_html: Buscar gemas… - sign_in: Ingresar + sign_in: Acceso sign_out: Salir - sign_up: Registrarte + sign_up: Registro + mfa_banner_html: mailer: confirm_your_email: Por favor confirma tu dirección de correo con el enlace enviado. - confirmation_subject: Por favor confirma tu dirección de correo con RubyGems.org - link_expiration_explanation_html: + confirmation_subject: Por favor confirma tu dirección de correo con %{host} + link_expiration_explanation_html: Por favor ten en cuenta que este enlace es válido + solo durante 3 horas. Puedes solicitar un enlace actualizado usando la página + de reenvío del correo + de confirmación. email_confirmation: title: CONFIRMACIÓN DE EMAIL - subtitle: ¡Último paso! - confirmation_link: Confirma dirección de correo - welcome_message: Bienvenidos a RubyGems.org! Haz clic en el enlace de abajo para verificar tu dirección de correo. + subtitle: "¡Último paso!" + confirmation_link: Confirma la dirección de correo + welcome_message: Bienvenidos a %{host}! Haz clic en el enlace de abajo para + verificar tu dirección de correo. email_reset: title: CAMBIO DE EMAIL - subtitle: ¡Hola %{handle}! - visit_link_instructions: Cambiaste tu dirección de correo en RubyGems.org. Por favor visita el siguiente enlace para re-activar tu cuenta. + subtitle: "¡Hola %{handle}!" + visit_link_instructions: Cambiaste tu dirección de correo en %{host}. Por favor + visita el siguiente enlace para reactivar tu cuenta. deletion_complete: title: ELIMINACIÓN COMPLETADA - subtitle: ¡Adiós! - subject: Tu cuenta se ha eliminado de rubygems.org - body_html: Se ha procesado tu solicitud para eliminar tu cuenta de rubygems.org. Siempre puedes crear una nueva cuenta en nuestra página %{sign_up}. + subtitle: "¡Adiós!" + subject: Tu cuenta se ha eliminado de %{host} + body_html: Se ha procesado tu solicitud para eliminar tu cuenta de %{host}. + Siempre puedes crear una nueva cuenta en nuestra página %{sign_up}. deletion_failed: title: ELIMINACIÓN FALLIDA - subtitle: ¡Lo sentimos! - subject: Tu solicitud para eliminar tu cuenta de rubygems.org ha fallado - body_html: Has solicitado eliminar tu cuenta de rubygems.org. Lamentablemente, no hemos podido procesar tu pedido. Por favor inténtalo de nuevo más adelante o %{contact} si el problema persiste. + subtitle: "¡Lo sentimos!" + subject: Tu solicitud para eliminar tu cuenta de %{host} ha fallado + body_html: Has solicitado eliminar tu cuenta de %{host}. Lamentablemente, no + hemos podido procesar tu pedido. Por favor inténtalo de nuevo más adelante + o %{contact} si el problema persiste. notifiers_changed: - subject: Cambiaste la configuración de tus notificaciones por email de RubyGems.org + subject: Cambiaste la configuración de tus notificaciones por email de %{host} title: NOTIFICACIONES POR EMAIL - subtitle: ¡Hola %{handle}! - 'on': - off_html: + subtitle: "¡Hola %{handle}!" + 'on': 'ON' + off_html: "OFF" gem_pushed: - subject: Gema %{gem} subida a RubyGems.org + subject: Gema %{gem} subida a %{host} title: GEMA SUBIDA gem_yanked: - subject: - title: + subject: Gema %{gem} eliminada de %{host} + title: GEMA ELIMINADA reset_api_key: - subject: - title: - subtitle: + subject: Clave de API de %{host} restablecida + title: CLAVE DE API RESTABLECIDA + subtitle: "¡Hola %{handle}!" + webauthn_credential_created: + subject: Añadido nuevo dispositivo de seguridad en %{host} + title: DISPOSITIVO DE SEGURIDAD AÑADIDO + subtitle: "¡Hola %{handle}!" + webauthn_credential_removed: + subject: Eiminado dispositivo de seguridad de %{host} + title: DISPOSITIVO DE SEGURIDAD ELIMINADO + subtitle: "¡Hola %{handle}!" + totp_enabled: + subject: Aplicación de autenticación activada en %{host} + title: APLICACIÓN DE AUTENTICACIÓN ACTIVADA + subtitle: "¡Hola %{handle}!" + totp_disabled: + subject: Aplicación de autenticación desactivada en %{host} + title: APLICACIÓN DE AUTENTICACIÓN DESACTIVADA + subtitle: "¡Hola %{handle}!" email_reset_update: - subject: - title: + subject: Has solicitado actualizar la dirección de correo en %{host} + title: SOLICITUD DE ACTUALIZACIÓN DE CORREO ownership_confirmation: - subject: - title: - subtitle: - body_html: - link_expiration_explanation_html: + subject: Por favor confirma la propiedad de la gema %{gem} en %{host} + title: CONFIMACIÓN DE PROPIEDAD + subtitle: "¡Hola %{handle}!" + body_text: Has sido añadido/a como propietario/a de la gema %{gem} por %{authorizer}. + Por favor visita el enlace siguiente para confirmarlo. + body_html: Has sido añadido/a como propietario/a de la gema %{gem} + por %{authorizer}. Por favor visita el enlace siguiente para + confirmarlo. + link_expiration_explanation_html: Ten en cuenta que este enlace es válido solo + durante %{expiry_hours}. Puedes reenviar el correo de confirmación desde la + página de la gema %{gem} + una vez autenticado. owner_added: - subject_self: - subject_others: - title: - subtitle: - body_self_html: - body_others_html: + subject_self: Has sido añadido/a como propietario/a de la gema %{gem} + subject_others: El usuario %{owner_handle} ha sido añadido/a como propietario/a + de la gema %{gem} + title: PROPIETARIO AÑADIDO + subtitle: "¡Hola %{handle}!" + body_self_html: Has sido añadido/a como propietario/a de la gema %{gem} en %{host}. + body_others_html: El usuario %{owner_handle} ha sido añadido/a como propietario/a + de la gema %{gem} + por %{authorizer}. Recibes esta notificación por ser propietario de + %{gem}. owner_removed: + subject: Has sido eliminado como propietario/a de la gema %{gem} + title: PROPIETARIO ELIMINADO + subtitle: "¡Hola %{handle}!" + body_html: Has sido eliminado como propietario/a de la gema %{gem} + en %{host} por %{remover}. + owner_updated: subject: title: subtitle: body_html: + body_text: + ownerhip_request_closed: + title: CANDIDATURA A PROPIETARIO + subtitle: "¡Hola %{handle}!" + body_html: Gracias por proponerte como propietario para %{gem}. + Lamentamos informarte de que el dueño de la gema ha cerrado tu solicitud. + ownerhip_request_approved: + body_html: "¡Enhorabuena! Tu candidatura a propietario de %{gem} + ha sido aprobada. Se te ha añadido a la lista de propietarios de la gema." + new_ownership_requests: + body_html: + zero: No hay nuevas candidaturas a propietario para %{gem}. + one: Hay una nueva candidatura a propietario para %{gem}. + Por favor haz click en el botón siguiente para verla. + other: Hay %{count} nuevas candidaturas a propietario para %{gem}. + Por favor haz click en el botón siguiente para ver todas las candidaturas. + button: CANDIDATURAS A PROPIETARIO + disable_notifications: Para dejar de recibir estos mensajes actualiza tus + owners_page: PROPIETARIOS + web_hook_deleted: + title: WEBHOOK ELIMINADO + subject: Se ha borrado tu webhook en %{host} + subtitle: "¡Hola %{handle}!" + body_text: Tu webhook que enviaba peticiones POST a %{url} ha sido eliminado + tras %{failures} fallos. + body_html: Tu webhook que enviaba peticiones POST a %{url} + ha sido eliminado tras %{failures} fallos + global_text: Este webhook se ejecutaba antes cuando se subía cualquier gema. + global_html: Este webhook se ejecutaba antes cuando se subía cualquier + gema. + gem_text: Este webhook se ejecutaba antes cuando se subía %{gem}. + gem_html: Este webhook se ejecutaba antes cuando se subía %{gem}. + web_hook_disabled: + title: WEBHOOK DESACTIVADO + subject: Se ha desactivado tu webhook en %{host} + subtitle: "¡Hola %{handle}!" + body_text: | + Tu webhook que enviaba peticiones POST a %{url} ha sido desactivado debido a %{disabled_reason}. + La última que se ejecutó con éxito fue en %{last_success} y desde entonces ha fallado %{failures_since_last_success} veces. + Puedes borrar este webhook usando el comando `%{delete_command}`. + body_html: | +

          Tu webhook que enviaba peticiones POST a %{url} ha sido desactivado debido a %{disabled_reason}.

          +

          La última que se ejecutó con éxito fue en %{last_success} y desde entonces ha fallado %{failures_since_last_success} veces.

          +

          Puedes borrar este webhook usando el comando %{delete_command}.

          + global_text: Este webhook se ejecutaba antes cuando se subía cualquier gema. + global_html: Este webhook se ejecutaba antes cuando se subía cualquier + gema. + gem_text: Este webhook se ejecutaba antes cuando se subía %{gem}. + gem_html: Este webhook se ejecutaba antes cuando se subía %{gem}. + gem_trusted_publisher_added: + title: + admin_manual: + title: news: show: title: Nuevos lanzamientos — Todas las Gemas @@ -223,17 +440,38 @@ es: title: Nuevos lanzamientos — Gemas más populares pages: about: - founding_html: El proyecto comenzó en abril del 2009 por %{founder}, y desde entonces ha crecido para incluir las contribuciones de %{contributors} y %{downloads}. A partir de RubyGems 1.3.6 el sitio se renombró de Gemcutter a %{title} para hacer más claro el rol del mismo en la comunidad de Ruby. - support_html: A pesar de que RubyGems.org no es mantenido por una compañía en concreto, muchas nos han ayudado. El diseño actual, las ilustraciones y el desarrollo de front-end del sitio fue cortesía de %{dockyard}. %{github} también ha sido importante por ayudarnos a colaborar y compartir código de manera sencilla. El sitio comenzó en %{heroku}, cuyo excelente servicio ayudó a probar que RubyGems es una solución viable en la que toda la comunidad puede confiar. Nuestra infraestructura está alojada actualmente en %{aws}. - technical_html: 'Algunos aspectos técnicos del sitio: Es 100% Ruby. El sitio principal es una aplicación %{rails}. Las gemas se almacenan en %{s3}, son servidas por %{fastly}, y el tiempo entre que se publica una gema y que está lista para instalarse es mínimo. Para más información, %{source_code}, cuya licencia es %{license} en GitHub.' + contributors_amount: "%{count} Rubystas" + downloads_amount: millones de descargas de gemas + checkout_code: por favor echa un ojo al código + mit_licensed: MIT + logo_header: "¿Buscas nuestro logo?" + logo_details: Usa el botón de descarga y obtendrás tres archivos .PNG y un .SVG + del logo de RubyGems. + founding_html: El proyecto comenzó en abril del 2009 por %{founder}, y desde + entonces ha crecido para incluir las contribuciones de %{contributors} y %{downloads}. + A partir de RubyGems 1.3.6 el sitio se renombró de Gemcutter a %{title} para + hacer más claro el rol del mismo en la comunidad de Ruby. + support_html: A pesar de que RubyGems.org no es mantenido por una compañía en + concreto, muchas nos han ayudado. El diseño actual, las ilustraciones y el + desarrollo de front-end del sitio fue cortesía de %{dockyard}. %{github} también + ha sido importante por ayudarnos a colaborar y compartir código de manera + sencilla. El sitio comenzó en %{heroku}, cuyo excelente servicio ayudó a probar + que RubyGems es una solución viable en la que toda la comunidad puede confiar. + Nuestra infraestructura está alojada actualmente en %{aws}. + technical_html: 'Algunos aspectos técnicos del sitio: Es 100% Ruby. El sitio + principal es una aplicación %{rails}. Las gemas se almacenan en %{s3}, son + servidas por %{fastly}, y el tiempo entre que se publica una gema y que está + lista para instalarse es mínimo. Para más información, %{source_code}, cuya + licencia es %{license} en GitHub.' title: Acerca de purpose: better_api: Ofrecer una mejor API para manejar gemas enable_community: Permitirle a la comunidad contribuir para mejorar este sitio - header: 'Bienvenido a %{site}, el servicio de alojamiento de gemas para la comunidad de Ruby. Este proyecto tiene 3 objetivos:' + header: 'Bienvenido a %{site}, el servicio de alojamiento de gemas para la + comunidad de Ruby. Este proyecto tiene 3 objetivos:' transparent_pages: Crear páginas de proyecto más accesibles y transparentes data: - title: RubyGems.org Data + title: Volcado de datos de RubyGems.org download: title: Descargar RubyGems faq: @@ -243,114 +481,282 @@ es: security: title: Seguridad sponsors: - title: Sponsors + title: Patrocinadores + password_mailer: + change_password: + closing: + opening: + title: CAMBIAR CONTRASEÑA + subtitle: "¡Hola %{handle}!" passwords: edit: submit: Guardar esta contraseña title: Restablecer contraseña + token_failure: Por favor verifica la URL o inténtalo nuevamente. new: submit: Restablecer contraseña title: Cambiar tu contraseña - will_email_notice: Te enviaremos el enlace para cambiar tu contraseña por correo electrónico. - otp_prompt: - authenticate: Autenticar + will_email_notice: Te enviaremos el enlace para cambiar tu contraseña por correo + electrónico. + create: + success: + failure_on_missing_email: + update: + failure: multifactor_auths: + session_expired: Ha expirado tu sesión en la página de acceso. + require_mfa_enabled: No se ha activado la autenticación de múltiples factores. + Primero tienes que activarla. + require_webauthn_enabled: No tienes ningún dispositivo de seguridad activado. + Primero debes asociar un dispositivo a tu cuenta. + setup_required_html: Por la seguridad de tu cuenta y de tus gemas se te requiere + activar la autenticación de múltiples factores. Lee por favor el artículo + en nuestro blog para saber más detalles. + setup_recommended: Por la seguridad de tu cuenta y de tus gemas te animamos a + configurar la autenticación de múltiples factores. En el futuro será obligatorio + tener AMF activada en tu cuenta. + strong_mfa_level_required_html: Por la seguridad de tu cuenta y de tus gemas es + necesario que cambies el nivel de AMF a "Interfaz de usuario y firma de gemas" + o "Interfaz de usuario y API". Lee por favor el artículo + en nuestro blog para saber más detalles. + strong_mfa_level_recommended: Por la seguridad de tu cuenta y de tus gemas te + recomendamos que cambies el nivel de AMF a "Interfaz de usuario y firma de gemas" + o "Interfaz de usuario y API". En el futuro será obligatorio tener AMF configurada + en alguno de esos niveles. + setup_webauthn_html: "\U0001F389 ¡Ahora soportamos dispositivos de seguridad! + Aumenta la seguridad de tu cuenta configurando + un nuevo dispositivo." + api: + mfa_required: + mfa_required_not_yet_enabled: + mfa_required_weak_level_enabled: + mfa_recommended_not_yet_enabled: + mfa_recommended_weak_level_enabled: + recovery: + continue: Continuar + title: Códigos de recuperación + saved: Declaro haber guardado mis códigos de recuperación. + confirm_dialog: + note_html: Por favor copia y guarda + estos códigos de recuperación. Puedes usar estos códigos para acceder y restablecer + tu autenticación de múltiples factores si pierdes tu dispositivo. Cada código + se puede usar una sola vez. + already_generated: Ya deberías haber guardado tus códigos de recuperación. + update: + invalid_level: Nivel de AMF inválido. + success: Has actualizado exitosamente la autenticación de múltiples factores. + prompt: + webauthn_credential_note: Autentícate con un dispositivo de seguridad como Touch + Id, YubiKey, etc. + sign_in_with_webauthn_credential: Autenticar con dispositivo de seguridad + otp_code: Código OTP + otp_or_recovery: OTP o código de recuperación + recovery_code: Código de recuperación + recovery_code_html: Puedes utilizar un código de recuperación válido si has perdido el acceso + a tu dispositivo de seguridad o de autenticación de múltiples factores. + security_device: Dispositivo de seguridad + verify_code: Verificar código + totps: incorrect_otp: Tu código OTP no es correcto. - otp_code: código OTP - require_mfa_disabled: Se ha activado la autenticación de múltiples factores. Primero tienes que desactivarla. - require_mfa_enabled: No se ha activado la autenticación de múltiples factores. Primero tienes que activarla. + require_totp_disabled: La autenticación de múltiples factores basada en OTP ya + está activa. Para reconfigurarla debes primero eliminarla. + require_totp_enabled: No tienes aplicación de autenticación activa. Debes activarla + primero. new: title: Activando autenticación de múltiples factores - scan_prompt: Por favor escanea el código QR con tu aplicación de Autenticación. Si no puedes escanear el código, agrega manualmente información abajo en tu aplicación. - otp_prompt: Escribe el código de la aplicación de Autenticación para continuar. + scan_prompt: Por favor escanea el código QR con tu aplicación de autenticación. + Si no puedes escanear el código, agrega manualmente la información siguiente + a tu aplicación. + otp_prompt: Escribe el código de la aplicación de autenticación para continuar. confirm: He mantenido seguros mis códigos de recuperación. enable: Activar - account: "Cuenta: %{account}" - key: "Clave: %{key}" - time_based: "Basado en tiempo: Sí" + account: 'Cuenta: %{account}' + key: 'Clave: %{key}' + time_based: 'Basado en tiempo: Sí' create: - qrcode_expired: El código QR y la clave han vencido. Por favor intenta nuevamente registrar un nuevo dispositivo. - success: Has activado exitosamente la autenticación de múltiples factores. - recovery: - continue: Continuar - title: Códigos de recuperación - note: "DEBES mantener seguros los códigos de recuperación para prevenir la pérdida de tu cuenta. Cada código puede ser usado una vez si pierdes tu Autenticador." + qrcode_expired: El código QR y la clave han vencido. Por favor intenta otra + vez registrar un nuevo dispositivo. + success: Has activado con éxito la autenticación de múltiples factores. destroy: success: Has desactivado exitosamente la autenticación de múltiples factores. - update: - success: Has actualizado exitosamente la autenticación de múltiples factores. notifiers: update: - success: Has actualizado exitosamente la configuración de tus notificaciones por email. + success: Has actualizado exitosamente la configuración de tus notificaciones + por correo. show: - info: + info: Para ayudar a detectar cambios no autorizados en gemas o en propietarios, + te enviamos un correo electrónico cada vez que se sube o se elimina una versión + de cualquiera de tus gemas o se le añade un nuevo propietario. Recibiendo + y leyendo esos mensajes ayudas al ecosistema de Ruby. 'on': Activado 'off': Desactivado recommended: recomendado title: Notificación de email update: Actualizar - push_heading: - owner_heading: + owner_heading: Notificaciones de propietarios + owner_request_heading: Notificaciones de solicitud de propietarios + push_heading: Notificaciones Push + webauthn_verifications: + expired_or_already_used: El token del enlace utilizado ha expirado o ya ha sido + utilizado. + no_port: No se especifica el puerto. Por favor inténtalo de nuevo. + pending: La autenticación del dispositivo de seguridad está pendiente todavía. + prompt: + title: Autenticación con dispositivo de seguridad + authenticating_as: Autenticando como + authenticate: Auntenticar + no_webauthn_devices: No tienes dispositivos de seguridad activos + successful_verification: + title: "¡Éxito!" + close_browser: Por favor cierra este navegador. + failed_verification: + title: Error - Falló la verification + close_browser: Por favor cierra este navegador e inténtalo de nuevo. + owners: + confirm: + confirmed_email: Has sido añadido/a como propietario de la gema %{gem} + token_expired: El token de confirmación ha expirado. Por favor intenta reenviar + el token desde la página de la gema. + index: + add_owner: AÑADIR PROPIETARIO + name: PROPIETARIO/A + mfa: ESTADO DE AMF + status: ESTADO + confirmed_at: CONFIRMADO + added_by: AÑADIDO POR + action: ACCIÓN + email_field: Correo / Usuario + submit_button: Añadir a propietarios + info: añadir o eliminar propietarios + confirmed: Confirmado + pending: Pendiente + confirm_remove: "¿Seguro que quieres eliminar a este usuario de los propietarios?" + role: + role_field: + resend_confirmation: + resent_notice: Se ha reenviado un mensaje de confirmación a tu correo electrónico + create: + success_notice: Se ha añadido a %{handle} como propietario sin confirmar. Su + acceso como propietario se activará cuando haga click en el mensaje de confirmación + que se le ha enviado a su correo + destroy: + removed_notice: "%{owner_name} eliminado con éxito de la lista de propietarios" + failed_notice: No se puede eliminar al único propietario de una gema + mfa_required: La gema tiene activado el requerimiento de AMF, configura AMF en + tu cuenta por favor. + edit: + role: + title: + update: + update_current_user_role: + success_notice: settings: edit: title: Editar configuración + webauthn_credentials: Dispositivo de seguridad + no_webauthn_credentials: No tienes dispositivos de seguridad + webauthn_credential_note: Un dispositivo de seguridad puede ser cualquier dispositivo + que cumpla el estándar FIDO2 como las llaves biométrica y de seguridad. + otp_code: Código OTP o código de recuperación api_access: - confirm_reset: '¿Seguro? Este cambio no puede deshacerse.' - credentials_html: 'Si quieres usar %{gem_commands_link} desde la línea de comandos, vas a necesitar un archivo llamado %{gem_credentials_file}, el cual puedes generar usando el siguiente comando:' + confirm_reset: "¿Seguro? Este cambio no puede deshacerse." + credentials_html: 'Si quieres usar %{gem_commands_link} desde la línea de + comandos, vas a necesitar un archivo llamado %{gem_credentials_file}, el + cual puedes generar usando el siguiente comando:' key_is_html: Tu clave de API es %{key}. link_text: Comandos de la gema - reset: Reestablecer mi clave de API + reset: Restablecer mi clave de API + reset_all: Restablecer todas las claves de API de este ámbito title: Acceso por API reset_password: - title: Reestablecer contraseña + title: Restablecer contraseña mfa: multifactor_auth: Autenticación de múltiples factores - disabled: Todavía no activaste la Autenticación de Múltiples Factores. + otp: Aplicación de autenticación + disabled_html: No has activado todavía la autenticación de múltiples factores + basada en OTP. Por favor lee la guía + sobre AMF para informarte sobre los distintos niveles de AMF. go_settings: Registrar un nuevo dispositivo - enabled: Has activado la Autenticación de Múltiples Factores. Por favor ingresa el código OTP desde tu autenticador o alguna de tus claves de recuperación para cambiar tu nivel de Autenticación de Múltiples Factores para desactivarla. + level_html: Has activado la autenticación de múltiples factores. Pincha en + "Actualizar" para modificar el nivel de AMF. Por favor lee la guía + sobre AMF para informarte sobre los distintos niveles de AMF. + enabled_note: Has activado la autenticación de múltiples factores. Para desactivarla + usa tu OTP o uno de tus códigos de recuperación activos. update: Actualizar + disable: Desactivar + enabled: Activado + disabled: Desactivado level: title: Nivel de Autenticación de Múltiples Factores disabled: Desactivada ui_only: Solo Interfaz de Usuario ui_and_api: Interfaz de Usuario y API - ui_and_gem_signin: Interfaz de Usario y gem signin + ui_and_gem_signin: Interfaz de Usuario y firma de gemas profiles: + adoptions: + no_ownership_calls: No has creado llamadas a ser propietario para ninguna de + tus gemas + no_ownership_requests: No has creado ninguna petición para ser propietario + title: Adopción + subtitle_html: Pide nuevos responsables de mantenimiento o solicita propietarios + (leer + más) edit: - change_avatar: - email_awaiting_confirmation: Por favor confirma tu nueva dirección de correo %{unconfirmed_email} + change_avatar: Cambiar avatar + disabled_avatar_html: Se usa un avatar por defecto debido a la configuración + de privacidad del email. Para usar un Gravatar + personalizado activa la opción 'Mostrar correo electrónico en perfil público'. + Ten en cuenta que esto hará público tu correo." + email_awaiting_confirmation: Por favor confirma tu nueva dirección de correo + %{unconfirmed_email} enter_password: Por favor introduce tu contraseña - hide_email: Ocultar correo electrónico en perfil público - optional_twitter_username: Usuario de Twitter opcional. Será mostrado en tu perfil público + optional_full_name: Opcional. Será mostrado en tu perfil público + optional_twitter_username: Usuario de X opcional. Será mostrado en tu perfil + público + twitter_username: Usuario title: Editar perfil delete: delete: Eliminar delete_profile: Eliminar Perfil - warning: Eliminar tu perfil es una acción irreversible. No puede deshacerse contactando a soporte. ¡Por favor hazlo solo si estás seguro! + warning: Eliminar tu perfil es una acción irreversible. No puede deshacerse + contactando a soporte. ¡Por favor hazlo solo si estás seguro! delete: title: Eliminar perfil confirm: Confirmar - instructions: Lamentamos ver que te vas. Ingresa tu contraseña abajo y confírmalo. - list_only_owner_html: Estas gemas serán borradas cuando elimines tu cuenta. Si quieres agregar propietarios antes de eliminar tu cuenta, puedes usar el comando %{command_link}. - list_multi_owner: Perderás acceso a estas gemas, pero otros propietarios podrán seguir accediendo a ellas. - warning: Eliminar tu perfil es una acción irreversible. No puede deshacerse contactando a soporte. ¡Por favor hazlo solo si estás seguro! + instructions: Lamentamos ver que te vas. Introduce tu contraseña abajo y confírmalo. + list_only_owner_html: Estas gemas serán borradas cuando elimines tu cuenta. + Si quieres agregar propietarios antes de eliminar tu cuenta, puedes usar el + comando %{command_link}. + list_multi_owner: Perderás acceso a estas gemas, pero otros propietarios podrán + seguir accediendo a ellas. + warning: Eliminar tu perfil es una acción irreversible. No puede deshacerse + contactando a soporte. ¡Por favor hazlo solo si estás seguro! rubygem: owners_header: Propietarios destroy: - request_queued: Tu solicitud de eliminación de cuenta se ha puesto en cola. Te enviaremos un email de confirmación cuando haya sido procesada. + request_queued: Tu solicitud de eliminación de cuenta se ha puesto en cola. + Te enviaremos un email de confirmación cuando haya sido procesada. update: - confirmation_mail_sent: Recibirás un correo electrónico en los próximos minutos. Contiene instrucciones para confirmar tu nueva dirección de correo. + confirmation_mail_sent: Recibirás un correo electrónico en los próximos minutos. + Contiene instrucciones para confirmar tu nueva dirección de correo. updated: Tu perfil se a actualizado. + public_email: Mostrar correo electrónico en perfil público request_denied: La solicitud fue denegada. No pudimos verificar tu contraseña. + show: + title: Perfil de %{username} + security_events: + title: + description_html: rubygems: aside: - bundler_header: Gemfile - copied: ¡Copiado! - copy_to_clipboard: Copiar al portapapeles downloads_for_this_version: Para esta versión - install: instalar + gem_version_age: Versión publicada required_ruby_version: Versión de Ruby requerida required_rubygems_version: Versión de Rubygems requerida + requires_mfa: Nuevas versiones requieren AMF + released_with_mfa: Versión publicada con AMF links: badge: Badge bugs: Seguimiento de Bugs @@ -358,35 +764,52 @@ es: code: Código fuente docs: Documentación download: Descarga + funding: Financiación header: Enlace home: Página mail: Lista de Correo report_abuse: Reportar abusos reverse_dependencies: Dependencias inversas - review_changes: + review_changes: Revisar cambios rss: RSS subscribe: Suscribirse unsubscribe: Desuscribirse wiki: Wiki - ownership: - resend_ownership_confirmation: - blacklisted: - blacklisted_namespace: Este namespace está reservado por rubygems.org. + resend_ownership_confirmation: Reenviar confirmación + ownership: Propietarios + oidc: + api_key_role: + name: 'OIDC: %{name}' + new: 'OIDC: Crear' + trusted_publishers: + reserved: + reserved_namespace: Este namespace está reservado por rubygems.org. dependencies: - header: "dependencias de %{title}" + header: dependencias de %{title} gem_members: authors_header: Autores - not_using_mfa_warning_show: "* Algunos propietarios no están usando MFA. Haga click para ver la lista completa." - not_using_mfa_warning_hide: "* Los siguientes propietarios no están usando MFA. Haga click para ocultar." + self_no_mfa_warning_html: Considera por favor activar + la autenticación de múltiples factores (AMF) para mantener tu cuenta segura. + not_using_mfa_warning_show: "* Algunos propietarios no están usando AMF. Haga + click para ver la lista completa." + not_using_mfa_warning_hide: "* Los siguientes propietarios no están usando AMF. + Haga click para ocultar." owners_header: Propietarios pushed_by: Subida por - using_mfa_info: "* Todos los propietarios están usando MFA." + using_mfa_info: "* Todos los propietarios están usando AMF." yanked_by: Borrada por sha_256_checksum: SHA 256 checksum + signature_period: Periodo de validez de la firma + expired: Expirado + version_navigation: + previous_version: "← Versión anterior" + next_version: Siguiente versión → index: downloads: Descargas title: Gemas show: + bundler_header: Gemfile + install: instalar licenses_header: one: Licencia other: Licencias @@ -394,18 +817,34 @@ es: requirements_header: Requerimientos show_all_versions: Mostrar todas las versiones (%{count} total) versions_header: Versiones - yanked_notice: Esta versión fue borrada, y no está disponible para su descarga directa ni por otras gemas que puedan haber dependido de la misma. + yanked_notice: Esta versión fue borrada, y no está disponible para su descarga + directa ni por otras gemas que puedan haber dependido de la misma. show_yanked: - not_hosted_notice: Esta gema no está alojada actualmente en RubyGems.org. + not_hosted_notice: Esta gema no está alojada actualmente en RubyGems.org. Es + posible que ya exista alguna versión borrada de esta gema. reserved_namespace_html: - one: Esta gema existió previamente, pero fue eliminada por sus propietarios. El equipo de RubyGems.org ha reservado este nombre por un día más. Luego cualquiera podrá solicitar este nombre de gema usando `gem push`.
          Si eres el anterior propietario, puedes cambiar la propiedad de esta gema usando el comando `gem owner`. También puedes crear nuevas versiones de esta gema usando `gem push`. - other: Esta gema existió previamente, pero fue eliminada por sus propietarios. El equipo de RubyGems.org ha reservado este nombre por %{count} días más. Luego cualquiera podrá solicitar este nombre de gema usando `gem push`.
          Si eres el anterior propietario, puedes cambiar la propiedad de esta gema usando el comando `gem owner`. También puedes crear nuevas versiones de esta gema usando `gem push`. + one: Esta gema existió previamente, pero fue eliminada por sus propietarios. + El equipo de RubyGems.org ha reservado este nombre por un día más. Luego + cualquiera podrá solicitar este nombre de gema usando `gem push`.
          + Si eres el anterior propietario, puedes cambiar la propiedad de esta gema + usando el comando `gem owner`. También puedes crear nuevas versiones de + esta gema usando `gem push`. + other: Esta gema existió previamente, pero fue eliminada por sus propietarios. + El equipo de RubyGems.org ha reservado este nombre por %{count} días más. + Luego cualquiera podrá solicitar este nombre de gema usando `gem push`. +
          Si eres el anterior propietario, puedes cambiar la propiedad de esta + gema usando el comando `gem owner`. También puedes crear nuevas versiones + de esta gema usando `gem push`. + security_events: + title: + description_html: reverse_dependencies: index: - title: "Dependencias inversas para %{name}" - subtitle: + title: Dependencias inversas para %{name} + subtitle: La última versión de las siguientes gemas requieren %{name} + no_reverse_dependencies: Esta gema no tiene dependencias inversas search: - search_reverse_dependencies_html: "Buscar dependencias inversas Gems…" + search_reverse_dependencies_html: Buscar dependencias inversas… searches: advanced: name: Nombre @@ -413,77 +852,277 @@ es: description: Descripción downloads: Descargas updated: Actualizada + yanked: Borrada show: - subtitle: para %{query} + subtitle_html: para %{query} month_update: Actualizadas en el último mes (%{count}) week_update: Actualizadas en la última semana (%{count}) - filter: "Filtro:" - yanked: - suggestion: + filter: 'Filtro:' + yanked: Borradas (%{count}) + suggestion: Quizá querías decir sessions: new: - forgot_password: ¿Olvidaste tu contraseña? - resend_confirmation: ¿No recibiste tu correo electrónico de confirmación? + forgot_password: "¿Olvidaste tu contraseña?" + resend_confirmation: "¿No recibiste tu correo electrónico de confirmación?" verify: - title: - confirm: - notice: + title: Confirmar contraseña + confirm: Confirmar + notice: Por favor confirma tu contraseña para continuar. + create: + account_blocked: Tu cuenta ha sido bloqueada por el equipo de rubygems. Para + recuperar tu cuenta envía un mensaje a support@rubygems.org, por favor. stats: index: - title: + title: Estadísticas all_time_most_downloaded: Más descargadas total_downloads: Total de descargas total_gems: Gemas totales total_users: Usuarios totales users: create: - email_sent: Se ha enviado un correo de confirmación a tu casilla de correo electrónico. + email_sent: Se ha enviado un correo de confirmación a tu dirección de correo + electrónico. new: - have_account: ¿Ya tienes una cuenta? + have_account: "¿Ya tienes una cuenta?" versions: index: not_hosted_notice: Esta gema no está alojada actualmente en RubyGems.org. title: Todas las versiones de %{name} - versions_since: "%{count} versiones desde %{since}" + versions_since: + other: "%{count} versiones desde %{since}" + one: "%{count} versión desde %{since}" + imported_gem_version_notice: Esta versión de la gema se importó a RubyGems.org + el %{import_date}. La fecha que se muestra fue especificada por el autor en + el archivo gemspec. version: yanked: borrada - will_paginate: - next_label: Siguiente - page_gap: "…" - previous_label: Previa - page_entries_info: - multi_page: Mostrando %{model} %{from} - %{to} sobre un total de %{count} - multi_page_html: Mostrando %{model} %{from} - %{to} de un total de %{count} - single_page: - one: Mostrando 1 %{model} - other: Mostrando todos los %{count} %{model} - zero: No se encontró ningún %{model} - single_page_html: - one: Mostrando 1 %{model} - other: Mostrando todos los %{count} %{model} - zero: No se encontró ningún %{model} - owners: - confirm: - confirmed_email: - token_expired: + adoptions: index: - add_owner: - name: - mfa: - status: - confirmed_at: - added_by: - action: - info: - email_field: - submit_button: - confirmed: - pending: - confirm_remove: - resend_confirmation: - resent_notice: + title: Adopciones + subtitle_owner_html: Solicita nuevos responsables de mantenimiento para %{gem} + (leer + más) + subtitle_user_html: Solicita ser propietario de %{gem} (leer + más) + ownership_calls: Solicitud de propietarios + no_ownership_calls: No hay convocatorias de propietarios para %{gem}. Los dueños + de la gema no están buscando nuevos responsables de mantenimiento. + ownership_calls: + update: + success_notice: Convocatoria para propietarios de %{gem} cerrada. create: - success_notice: - destroy: - removed_notice: - failed_notice: + success_notice: Creada convocatoria para propietarios de %{gem}. + index: + title: Se buscan responsables de mantenimiento + subtitle_html: Gemas que buscan nuevos responsables de mantenimiento (leer + más) + share_requirements: Por favor especifica en que areas necesitas ayuda + note_for_applicants: 'Nota para candidatos:' + created_by: Creado por + details: Detalles + apply: Proponte + close: Cerrar + markup_supported_html: Etiquetas + Rdoc soportadas + create_call: Crear convocatoria para propietarios + ownership_requests: + create: + success_notice: Se ha enviado tu candidatura a propietario. + update: + approved_notice: Candidatura a propietario aprobada. %{name} añadido a propietarios. + closed_notice: Se ha cerrado la candidatura a propietario. + close: + success_notice: Se han cerrado todas las candidaturas a propietario de %{gem}. + ownership_requests: Candidaturas a propietario + note_for_owners: 'Nota para propietarios:' + your_ownership_requests: Tus candidaturas a propietario + close_all: Cerrar todas + approve: Aprobar + gems_published: Gemas publicadas + created_at: Creado el + no_ownership_requests: Las peticiones para unirse a tu proyecto aparecerán aquí. + Todavia no hay candidaturas a propietario para %{gem}. + create_req: Crea una candidatura a propietario + signin_to_create_html: Por favor accede + para crear una candidatura a propietario. + webauthn_credentials: + callback: + success: Has dado de alta con éxito un dispositivo de seguridad. + recovery: + continue: Continuar + title: Has añadido con éxito un dispositivo de seguridad + notice_html: Por favor copia y guarda + estos códigos de recuperación. Puedes utilizar estos códigos para acceder + si pierdes tu dispositivo de seguridad. Cada código solo se puede usar una + vez. + saved: Declaro haber guardado mis códigos de recuperación. + webauthn_credential: + confirm_delete: Credencial borrada + delete_failed: No se pudo borrar la credencial + delete: Borrar + confirm: "¿Seguro que quieres borrar esta credencial?" + saved: Dispositivo de seguridad creado con éxito + form: + new_device: Registra un nuevo dispositivo de seguridad + nickname: Apodo + submit: Registrar dispositivo + oidc: + api_key_roles: + index: + api_key_roles: Roles de clave API OIDC + new_role: Crear rol de clave API + show: + api_key_role_name: Rol de clave API %{name} + automate_gh_actions_publishing: Automatizar publicación de gemas con GitHub + Actions + view_provider: Ver proveedor %{issuer} + edit_role: Editar rol de clave API + delete_role: Borrar rol de clave API + confirm_delete: "¿Seguro que quieres borrar este rol?" + deleted_at_html: Este rol se borró hace %{time_html} y ya no puede usarse. + edit: + edit_role: Editar rol de clave API + git_hub_actions_workflow: + title: OIDC GitHub Actions Workflow para subir gema + configured_for_html: Este rol de clave API OIDC está configurado para permitir + subir %{link_html} desde GitHub Actions. + to_automate_html: Para automatizar lanzar %{link_html} cuando se suba una + nueva etiqueta, añade el siguiente workflow a tu repositorio. + not_github: Este rol de clave API OIDC no está configurado para usar GitHub + Actions. + not_push: Este rol de clave API OIDC no está configurado para permitir subir + gemas. + a_gem: una gema + instructions_html: 'Para lanzar una gema, crea la nueva versión y genera una etiqueta nueva (usando + rake release:source_control_push) a GitHub. El workflow empaquetará + y subirá automáticamente la gema a RubyGems.org. + + ' + new: + title: Nuevo rol de clave API OIDC + update: + success: Rol de clave API OIDC actualizado + create: + success: Rol de clave API OIDC creado + destroy: + success: Rol de clave API OIDC borrado + form: + add_condition: Añadir condición + remove_condition: Eliminar condición! + add_statement: Añadir declaración + remove_statement: Eliminar declaración + deleted: El rol se ha eliminado. + providers: + index: + title: Proveedores de OIDC + description_html: Estos son los proveedores de OIDC que están configurados + para RubyGems.org.
          Por favor, contacta con soporte si necesitas añadir + otro proveedor OIDC. + show: + title: Proveedor de OIDC + id_tokens: + index: + title: Tokens OIDC ID + show: + title: Token OIDC ID + rubygem_trusted_publishers: + index: + title: + subtitle_owner_html: + delete: + create: + description_html: + destroy: + success: + create: + success: + new: + title: + subtitle_owner_html: + pending_trusted_publishers: + index: + title: + valid_for_html: + delete: + create: + description_html: + destroy: + success: + create: + success: + new: + title: + trusted_publisher: + unsupported_type: + github_actions: + repository_owner_help_html: + repository_name_help_html: + workflow_filename_help_html: + environment_help_html: + pending: + rubygem_name_help_html: + duration: + minutes: + other: "%{count} minutos" + one: 1 minuto + hours: + other: "%{count} horas" + one: 1 hora + days: + other: "%{count} días" + one: 1 día + seconds: + other: "%{count} segundos" + one: 1 segundo + form: + optional: + events: + table_component: + event: + time: + additional_info: + redacted: + no_user_agent_info: + rubygem_event: + version: + version_pushed: + version_yanked: + version_unyanked: + version_html: + version_pushed_sha256_html: + version_pushed_by_html: + version_yanked_by_html: + owner: + owner_added: + owner_added_owner_html: + owner_added_authorizer_html: + owner_removed: + owner_removed_owner_html: + owner_removed_by_html: + owner_confirmed: + user_event: + user: + created: + email: + login: + login_success: + webauthn_login: + mfa_method: + mfa_device: + none: + email: + email_verified: + email_sent_subject: + email_sent_from: + email_sent_to: + api_key: + api_key_created: + api_key_deleted: + api_key_name: + api_key_scopes: + api_key_gem_html: + api_key_mfa: + not_required: \ No newline at end of file diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 85a7c5f107e..fb7fbccf6e1 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -1,17 +1,37 @@ +--- fr: + credentials_required: + copied: Copié! + copy_to_clipboard: Copier edit: Modification - failure_when_forbidden: Veuillez vérifier l'URL ou réessayer. + verification_expired: feed_latest: RubyGems.org | Derniers Gems feed_subscribed: RubyGems.org | Gems abonnés - footer_about_html: RubyGems.org est le service d’hébergement de la communauté Ruby. Publiez vos gems instantanément et utilisez-les. Utilisez l'API pour interagir et trouver des informations sur les gems disponibles. Contribuez et améliorez ce site avec nous ! - footer_sponsors_html: - footer_join_rt_html: + footer_about_html: RubyGems.org est le service d’hébergement de la communauté + Ruby. Publiez vos gems instantanément et utilisez-les. Utilisez l'API pour interagir + et trouver des informations sur les gems disponibles. Contribuez et améliorez + ce site avec nous ! + footer_sponsors_html: RubyGems.org est rendu possible grâce à un partenariat avec + la communauté Ruby au sens large. Fastly + fournisseur de bande passante et du support CDN, Ruby + Central qui couvre les frais d'infrastructure, et qui finance le développement + et l'administration des serveurs. Apprenez en plus + sur nos sponsors et comment ils travaillent entre eux. + footer_join_rt_html: Nous avons besoin de votre aide pour financer le travail des + développeurs/développeuses qui font que RubyGems.org fonctionne de manière fluide + pour tout le monde. Join Ruby + Central today. form_disable_with: Veuillez patienter... - invalid_page: + invalid_page: Le numéro de page est hors de portée. Redirection vers la page par + défaut. locale_name: Français none: not_found: Introuvable + forbidden: + api_gem_not_found: api_key_forbidden: + api_key_soft_deleted: + api_key_insufficient_scope: please_sign_up: Accès refusé. Inscrivez-vous sur https://rubygems.org please_sign_in: otp_incorrect: @@ -27,6 +47,11 @@ fr: update: Mise à jour try_again: Une erreur est survenue. Veuillez réessayer. advanced_search: Recherche avancée + authenticate: + helpers: + submit: + create: + update: activerecord: attributes: linkset: @@ -43,21 +68,102 @@ fr: user: avatar: Avatar email: Adresse e-mail + full_name: Nom complet handle: Pseudonyme password: Mot de passe + ownership/role: + owner: + admin: + maintainer: + api_key: + oidc_api_key_role: + oidc/id_token: + jti: + api_key_role: + oidc/api_key_role: + api_key_permissions: + oidc/trusted_publisher/github_action: + repository_owner_id: + oidc/pending_trusted_publisher: + rubygem_name: errors: messages: unpwn: blocked: + models: + api_key: + attributes: + expires_at: + inclusion: + organization: + attributes: + handle: + invalid: + ownership: + attributes: + user_id: + already_confirmed: + already_invited: + ownership_request: + attributes: + user_id: + taken: + existing: + user: + attributes: + handle: + invalid: + version: + attributes: + gem_full_name: + taken: + full_name: + taken: + oidc/rubygem_trusted_publisher: + attributes: + rubygem: + taken: + oidc/pending_trusted_publisher: + attributes: + rubygem_name: + unavailable: + models: + user: + api_key: + zero: + one: + other: + activemodel: + attributes: + oidc/provider/configuration: + jwks_uri: + id_token_signing_alg_values_supported: + errors: + models: + oidc/api_key_permissions: + attributes: + valid_for: + inclusion: + gems: + too_long: api_keys: + form: + exclusive_scopes: + rubygem_scope: + rubygem_scope_info: + multifactor_auth: + enable_mfa: + expiration: create: success: + invalid_gem: destroy: success: index: api_keys: name: scopes: + gem: age: last_access: action: @@ -70,22 +176,26 @@ fr: yank_rubygem: add_owner: remove_owner: + update_owner: access_webhooks: show_dashboard: + configure_trusted_publishers: reset: save_key: + mfa: + expiration: new: new_api_key: reset: success: update: success: + invalid_gem: edit: edit_api_key: - clearance_mailer: - change_password: - title: - subtitle: + invalid_key: + all_gems: + gem_ownership_removed: dashboards: show: creating_link_text: création @@ -95,18 +205,26 @@ fr: migrating_link_text: migration mine: Mes Gems my_subscriptions: Abonnements - no_owned_html: Vous n'avez pas encore publié de gem. Vous pouvez vérifier les guides de %{creating_link} ou de %{migrating_link} de gems depuis RubyForge. - no_subscriptions_html: Vous n'avez pas encore d'abonnements. Visitez %{gem_link} pour vous y abonner ! + no_owned_html: Vous n'avez pas encore publié de gem. Vous pouvez vérifier les + guides de %{creating_link} ou de %{migrating_link} de gems depuis RubyForge. + no_subscriptions_html: Vous n'avez pas encore d'abonnements. Visitez %{gem_link} + pour vous y abonner ! title: Dashboard + dependencies: + show: + click_to_expand: email_confirmations: create: - promise_resend: Si ce compte existe, vous recevrez un email avec un lien de confirmation pour l'activer. + promise_resend: Si ce compte existe, vous recevrez un email avec un lien de + confirmation pour l'activer. new: submit: Renvoyer la confirmation title: Renvoyer l'email de confirmation - will_email_notice: Si ce compte existe, vous recevrez un email avec un lien de confirmation pour l'activer. + will_email_notice: Si ce compte existe, vous recevrez un email avec un lien + de confirmation pour l'activer. update: confirmed_email: Votre adresse email a été vérifiée. + token_failure: Veuillez vérifier l'URL ou réessayer. home: index: downloads_counting_html: téléchargements à ce jour @@ -138,37 +256,48 @@ fr: tested_by: Testé par tracking_by: Tracking par uptime: Uptime + verified_by: + secured_by: + looking_for_maintainers: header: dashboard: Dashboard - edit_profile: settings: + edit_profile: search_gem_html: Recherche de Gems… sign_in: Connexion sign_out: Déconnexion sign_up: Inscription + mfa_banner_html: mailer: - confirm_your_email: Veuillez confirmer votre adresse email avec le lien qui vous a été envoyé par email. + confirm_your_email: Veuillez confirmer votre adresse email avec le lien qui vous + a été envoyé par email. confirmation_subject: Veuillez confirmer votre adresse email pour RubyGems.org link_expiration_explanation_html: email_confirmation: title: subtitle: confirmation_link: Confirmez l'adresse email - welcome_message: Bienvenue sur RubyGems.org! Veuillez cliquer sur le lien ci-dessous pour vérifier votre adresse email. + welcome_message: Bienvenue sur RubyGems.org! Veuillez cliquer sur le lien ci-dessous + pour vérifier votre adresse email. email_reset: title: subtitle: - visit_link_instructions: Vous avez changé d'adresse email sur RubyGems.org. Veuillez suivre l'URL suivant pour réactiver votre compte. + visit_link_instructions: Vous avez changé d'adresse email sur RubyGems.org. + Veuillez suivre l'URL suivant pour réactiver votre compte. deletion_complete: title: subtitle: subject: Votre compte a bien été supprimé de RubyGems.org - body_html: "Votre demande de suppression de compte sur RubyGems.org a bien été prise en compte. Vous pouvez toujours créer un nouveau compte sur la page %{sign_up}" + body_html: Votre demande de suppression de compte sur RubyGems.org a bien été + prise en compte. Vous pouvez toujours créer un nouveau compte sur la page + %{sign_up} deletion_failed: title: subtitle: subject: Votre demande de suppression de compte sur RubyGems.org a échoué - body_html: "Vous avez effectué une demande de suppression de compte sur RubyGems.org. Malheureusement, votre demande n'a pu aboutir. Veuillez réessayer plus tard ou nous %{contact} si le problème subsiste." + body_html: Vous avez effectué une demande de suppression de compte sur RubyGems.org. + Malheureusement, votre demande n'a pu aboutir. Veuillez réessayer plus tard + ou nous %{contact} si le problème subsiste. notifiers_changed: subject: title: @@ -185,6 +314,22 @@ fr: subject: title: subtitle: + webauthn_credential_created: + subject: + title: + subtitle: + webauthn_credential_removed: + subject: + title: + subtitle: + totp_enabled: + subject: + title: + subtitle: + totp_disabled: + subject: + title: + subtitle: email_reset_update: subject: title: @@ -192,6 +337,7 @@ fr: subject: title: subtitle: + body_text: body_html: link_expiration_explanation_html: owner_added: @@ -206,6 +352,50 @@ fr: title: subtitle: body_html: + owner_updated: + subject: + title: + subtitle: + body_html: + body_text: + ownerhip_request_closed: + title: + subtitle: + body_html: + ownerhip_request_approved: + body_html: + new_ownership_requests: + body_html: + zero: + one: + other: + button: + disable_notifications: + owners_page: + web_hook_deleted: + title: + subject: + subtitle: + body_text: + body_html: + global_text: + global_html: + gem_text: + gem_html: + web_hook_disabled: + title: + subject: + subtitle: + body_text: + body_html: + global_text: + global_html: + gem_text: + gem_html: + gem_trusted_publisher_added: + title: + admin_manual: + title: news: show: title: Nouvelles Versions - Toutes les Gems @@ -215,14 +405,35 @@ fr: title: Nouvelles Versions - Gems populaires pages: about: - founding_html: Le projet a commencé en avril 2009 par %{founder}, et a grandi avec les contribution de %{contributors} et %{downloads}. À la sortie de RubyGems 1.3.6, ce site a été renommé de Gemcutter à %{title} pour ancrer son rôle central dans la communauté Ruby. - support_html: Bien que Gemcutter n'appartienne à aucune entreprise en particulier, il a reçu l'aide de nombreuses d'entre elles. Le design actuel, les illustrations, et développement front-end de ce site nous ont été offerts par %{dockyard}. Nous n'aurions pas pu collaborer et partager de code facilement sans %{github}. Ce site s'est lancé sur %{heroku}, dont l'excellent service a fait de Gemcutter une solution viable pour que la communauté s'y appuie en toute confiance. Our infrastructure is currently hosted on %{aws}. - technical_html: 'Quelques informations sur les aspects techniques de ce site : il est en Ruby à 100%. Le site principal est une application %{rails}. Les Gems sont hébergés sur %{s3}, served by %{fastly}, permettant un record de vitesse entre le moment de publication et de mise à disposition de gems. Pour davantage d''informations, le %{source_code} est disponible sous licence %{license} sur GitHub.' + contributors_amount: + downloads_amount: + checkout_code: + mit_licensed: + logo_header: + logo_details: + founding_html: Le projet a commencé en avril 2009 par %{founder}, et a grandi + avec les contribution de %{contributors} et %{downloads}. À la sortie de RubyGems + 1.3.6, ce site a été renommé de Gemcutter à %{title} pour ancrer son rôle + central dans la communauté Ruby. + support_html: Bien que Gemcutter n'appartienne à aucune entreprise en particulier, + il a reçu l'aide de nombreuses d'entre elles. Le design actuel, les illustrations, + et développement front-end de ce site nous ont été offerts par %{dockyard}. + Nous n'aurions pas pu collaborer et partager de code facilement sans %{github}. + Ce site s'est lancé sur %{heroku}, dont l'excellent service a fait de Gemcutter + une solution viable pour que la communauté s'y appuie en toute confiance. + Our infrastructure is currently hosted on %{aws}. + technical_html: 'Quelques informations sur les aspects techniques de ce site + : il est en Ruby à 100%. Le site principal est une application %{rails}. Les + Gems sont hébergés sur %{s3}, served by %{fastly}, permettant un record de + vitesse entre le moment de publication et de mise à disposition de gems. Pour + davantage d''informations, le %{source_code} est disponible sous licence %{license} + sur GitHub.' title: À propos purpose: better_api: Fournir une meilleure API pour gérer les gems enable_community: Permettre à la communauté d'améliorer ce site - header: 'Bienvenue sur %{site}, le service d''hébergement des gems de la communauté Ruby. Ce projet a trois objectifs :' + header: 'Bienvenue sur %{site}, le service d''hébergement des gems de la communauté + Ruby. Ce projet a trois objectifs :' transparent_pages: Créer des pages de projet plus accessibles et transparentes data: title: @@ -236,41 +447,83 @@ fr: title: sponsors: title: + password_mailer: + change_password: + closing: + opening: + title: + subtitle: passwords: edit: submit: Enregistrer ce mot de passe title: Nouveau mot de passe + token_failure: Veuillez vérifier l'URL ou réessayer. new: submit: Réinitialisez votre mot de passe title: Changement de mot de passe will_email_notice: Nous vous enverrons un email pour changer votre mot de passe. - otp_prompt: - authenticate: + create: + success: + failure_on_missing_email: + update: + failure: multifactor_auths: + session_expired: + require_mfa_enabled: Votre authentification multifacteur n'a pas été activée. + Vous devez d'abord l'activer. + require_webauthn_enabled: + setup_required_html: + setup_recommended: + strong_mfa_level_required_html: + strong_mfa_level_recommended: + setup_webauthn_html: + api: + mfa_required: + mfa_required_not_yet_enabled: + mfa_required_weak_level_enabled: + mfa_recommended_not_yet_enabled: + mfa_recommended_weak_level_enabled: + recovery: + continue: Continuer + title: Codes de récupération + saved: + confirm_dialog: + note_html: + already_generated: + update: + invalid_level: + success: + prompt: + webauthn_credential_note: + sign_in_with_webauthn_credential: + otp_code: + otp_or_recovery: + recovery_code: + recovery_code_html: + security_device: + verify_code: + totps: incorrect_otp: Votre clé OTP est incorrecte. - otp_code: clé OTP - require_mfa_disabled: Votre authentification multifacteur a été activée. Vous devez d'abord la désactiver. - require_mfa_enabled: Votre authentification multifacteur n'a pas été activée. Vous devez d'abord l'activer. + require_totp_disabled: + require_totp_enabled: new: title: Activer l'authentification multifacteur - scan_prompt: Veuillez scanner le QR code avec votre app d'authentification. Si vous ne pouvez pas scanner le code, veuillez saisir manuellement les informations ci-dessous dans votre app. - otp_prompt: Veuillez taper le code de l'application d'authentification pour poursuivre. + scan_prompt: Veuillez scanner le QR code avec votre app d'authentification. + Si vous ne pouvez pas scanner le code, veuillez saisir manuellement les informations + ci-dessous dans votre app. + otp_prompt: Veuillez taper le code de l'application d'authentification pour + poursuivre. confirm: J'ai gardé mes codes de récupération en sécurité. enable: Activer - account: "Compte: %{account}" - key: "Clé: %{key}" + account: 'Compte: %{account}' + key: 'Clé: %{key}' time_based: create: - qrcode_expired: Le QR code et la clé sont expirés. Veuillez essayer d'enregistrer un nouvel appareil. + qrcode_expired: Le QR code et la clé sont expirés. Veuillez essayer d'enregistrer + un nouvel appareil. success: Vous avez activé l'authentification multifacteur. - recovery: - continue: Continuer - title: Codes de récupération - note: "Vous DEVEZ garder les codes de récupération en sécurité pour ne pas perdre votre compte. Chacun des codes peut être utilisé une fois si vous perdez votre authentificateur" destroy: success: Vous avez désactivé l'authentification multifacteur. - update: - success: notifiers: update: success: @@ -281,26 +534,88 @@ fr: recommended: title: update: - push_heading: owner_heading: + owner_request_heading: + push_heading: + webauthn_verifications: + expired_or_already_used: + no_port: + pending: + prompt: + title: + authenticating_as: + authenticate: + no_webauthn_devices: + successful_verification: + title: + close_browser: + failed_verification: + title: + close_browser: + owners: + confirm: + confirmed_email: + token_expired: + index: + add_owner: + name: + mfa: + status: + confirmed_at: + added_by: + action: + email_field: + submit_button: + info: + confirmed: + pending: + confirm_remove: + role: + role_field: + resend_confirmation: + resent_notice: + create: + success_notice: + destroy: + removed_notice: + failed_notice: + update: + update_current_user_role: + success_notice: + edit: + role: + title: + mfa_required: settings: edit: title: + webauthn_credentials: + no_webauthn_credentials: + webauthn_credential_note: + otp_code: api_access: confirm_reset: Êtes-vous sûr ? Cette action ne peut être annulée. - credentials_html: 'Si vous voulez utiliser %{gem_commands_link} de la ligne de commande, vous aurez besoin d''un fichier %{gem_credentials_file}, que vous pouvez générer avec la commande suivante :' + credentials_html: 'Si vous voulez utiliser %{gem_commands_link} de la ligne + de commande, vous aurez besoin d''un fichier %{gem_credentials_file}, que + vous pouvez générer avec la commande suivante :' key_is_html: Votre clé API est %{key}. link_text: des commandes gems reset: Remise à zéro de votre clé API + reset_all: title: Accès API reset_password: title: Renvoi de mot de passe mfa: multifactor_auth: Authentification Multifacteur - disabled: Vous n'avez pas encore activé l'authentification multifacteur. + otp: + disabled_html: go_settings: Enregistrer un nouvel appareil - enabled: Vous avez activé l'authentification multifacteur. Veuillez entrer l'OTP de votre authentificateur ou un de vos codes de récupération pour le désactiver. + level_html: + enabled_note: update: + disable: + enabled: + disabled: level: title: disabled: @@ -308,41 +623,66 @@ fr: ui_and_api: ui_and_gem_signin: profiles: + adoptions: + no_ownership_calls: + no_ownership_requests: + title: + subtitle_html: edit: change_avatar: - email_awaiting_confirmation: Veuillez confirmer votre nouvelle adresse email %{unconfirmed_email} + disabled_avatar_html: + email_awaiting_confirmation: Veuillez confirmer votre nouvelle adresse email + %{unconfirmed_email} enter_password: Veuillez entrer le mot de passe de votre compte - hide_email: Ne pas afficher l'email dans le profil public - optional_twitter_username: Nom d'utilisateur Twitter optionnel. Sera affiché publiquement + optional_full_name: + optional_twitter_username: Nom d'utilisateur X optionnel. Sera affiché publiquement + twitter_username: Pseudonyme title: Modification de profil delete: delete: Supprimer delete_profile: Supprimer le profil - warning: La suppression de votre profil est une action irréversible. Cela ne peut pas être annulé en contactant le support. Réfléchissez-bien avant de continuer ! + warning: La suppression de votre profil est une action irréversible. Cela + ne peut pas être annulé en contactant le support. Réfléchissez-bien avant + de continuer ! delete: title: Supprimer le profil confirm: Confirmer - instructions: Nous sommes désolés de vous voir partir. Veuillez entrer votre mot de passe dans le champ et confirmer. - list_only_owner_html: Ces gems seront effacées quand vous effacerez votre profil. Si vous désirez ajouter des propriétaires avant de supprimer votre profil, vous pouvez utiliser la commande %{command_link}. - list_multi_owner: Vous allez perdre l'accès à ces gems mais d'autres propriétaires de ces gems y ont toujours accès. - warning: La suppression de votre profil est une action irréversible. Cela ne peut pas être annulé en contactant le support. Réfléchissez-bien avant de continuer ! + instructions: Nous sommes désolés de vous voir partir. Veuillez entrer votre + mot de passe dans le champ et confirmer. + list_only_owner_html: Ces gems seront effacées quand vous effacerez votre profil. + Si vous désirez ajouter des propriétaires avant de supprimer votre profil, + vous pouvez utiliser la commande %{command_link}. + list_multi_owner: Vous allez perdre l'accès à ces gems mais d'autres propriétaires + de ces gems y ont toujours accès. + warning: La suppression de votre profil est une action irréversible. Cela ne + peut pas être annulé en contactant le support. Réfléchissez-bien avant de + continuer ! rubygem: owners_header: Propriétaires destroy: - request_queued: Votre demande de suppression de compte a été envoyée. Nous vous enverrons un email de confirmation lorsque votre demande aura été prise en compte. + request_queued: Votre demande de suppression de compte a été envoyée. Nous vous + enverrons un email de confirmation lorsque votre demande aura été prise en + compte. update: - confirmation_mail_sent: Vous allez recevoir un email dans quelques minutes. Il contient les instructions pour confirmer votre nouvelle adresse email. + confirmation_mail_sent: Vous allez recevoir un email dans quelques minutes. + Il contient les instructions pour confirmer votre nouvelle adresse email. updated: Votre profil a été mis à jour. - request_denied: Cette demande a été refusée. Nous n'avons pas pu vérifier votre mot de passe. + public_email: Afficher l'email dans le profil public + request_denied: Cette demande a été refusée. Nous n'avons pas pu vérifier votre + mot de passe. + show: + title: Profil de %{username} + security_events: + title: + description_html: rubygems: aside: - bundler_header: Gemfile - copied: Copié! - copy_to_clipboard: Copier downloads_for_this_version: Pour cette version - install: installation + gem_version_age: Version publiée required_ruby_version: Version de Ruby requise required_rubygems_version: + requires_mfa: + released_with_mfa: links: badge: Badge bugs: Suivi de bugs @@ -350,6 +690,7 @@ fr: code: Code Source docs: Documentation download: Télécharger + funding: header: Liens home: Page d'accueil mail: Liste de diffusion @@ -360,14 +701,20 @@ fr: subscribe: Abonnement unsubscribe: Désabonement wiki: Wiki - ownership: resend_ownership_confirmation: - blacklisted: - blacklisted_namespace: Ce nom est réservé par rubygems.org. + ownership: + oidc: + api_key_role: + name: + new: + trusted_publishers: + reserved: + reserved_namespace: Ce nom est réservé par rubygems.org. dependencies: header: Dépendances de %{title} gem_members: authors_header: Auteurs + self_no_mfa_warning_html: not_using_mfa_warning_show: not_using_mfa_warning_hide: owners_header: Propriétaires @@ -375,10 +722,17 @@ fr: using_mfa_info: yanked_by: sha_256_checksum: Total de contrôle SHA 256 + signature_period: + expired: + version_navigation: + previous_version: "← Version précédente" + next_version: Version suivante → index: downloads: Téléchargements title: Gems show: + bundler_header: Gemfile + install: installation licenses_header: one: License other: Licenses @@ -386,18 +740,35 @@ fr: requirements_header: Dépendances show_all_versions: Voir toutes les versions (%{count}) versions_header: Versions - yanked_notice: 'Retrait de Gem : disponible ni directement, ni pour les gems qui en dépendraient.' + yanked_notice: 'Retrait de Gem : disponible ni directement, ni pour les gems + qui en dépendraient.' show_yanked: not_hosted_notice: Gem non hébergé sur Rubygems pour le moment. reserved_namespace_html: - one: Cette gem existait, mais a été supprimée par son propriétaire. L'équipe RubyGems.org a réservé ce nom de gem pour un jour supplémentaire. Une fois ce temps écoulé, quiconque pourra utiliser ce nom de gem en utilisant gem push.
          Si vous êtes le propriétaire précédent de cette gem, vous pouvez modifier la propriété de cette gem en utilisant la commande de gem owner. Vous pouvez également créer de nouvelles versions de cette gem avec gem push. - other: Cette gem existait, mais a été supprimée par son propriétaire. L'équipe RubyGems.org a réservé ce nom de gem pour %{count} jours supplémentaires. Une fois ce temps écoulé, quiconque pourra utiliser ce nom de gem en utilisant gem push.
          Si vous êtes le propriétaire précédent de cette gem, vous pouvez modifier la propriété de cette gem en utilisant la commande de gem owner. Vous pouvez également créer de nouvelles versions de cette gem avec gem push. + one: Cette gem existait, mais a été supprimée par son propriétaire. L'équipe + RubyGems.org a réservé ce nom de gem pour un jour supplémentaire. Une fois + ce temps écoulé, quiconque pourra utiliser ce nom de gem en utilisant gem + push.
          Si vous êtes le propriétaire précédent de cette gem, vous pouvez + modifier la propriété de cette gem en utilisant la commande de gem owner. + Vous pouvez également créer de nouvelles versions de cette gem avec gem + push. + other: Cette gem existait, mais a été supprimée par son propriétaire. L'équipe + RubyGems.org a réservé ce nom de gem pour %{count} jours supplémentaires. + Une fois ce temps écoulé, quiconque pourra utiliser ce nom de gem en utilisant + gem push.
          Si vous êtes le propriétaire précédent de cette gem, vous + pouvez modifier la propriété de cette gem en utilisant la commande de gem + owner. Vous pouvez également créer de nouvelles versions de cette gem avec + gem push. + security_events: + title: + description_html: reverse_dependencies: index: - title: "Dépendances inversées pour %{name}" + title: Dépendances inversées pour %{name} subtitle: + no_reverse_dependencies: search: - search_reverse_dependencies_html: "Chercher des dépendances inversées Gems…" + search_reverse_dependencies_html: Chercher des dépendances inversées Gems… searches: advanced: name: Nom @@ -405,8 +776,9 @@ fr: description: Description downloads: Téléchargements updated: Mis à jour + yanked: show: - subtitle: pour %{query} + subtitle_html: pour %{query} month_update: week_update: filter: @@ -420,6 +792,8 @@ fr: title: confirm: notice: + create: + account_blocked: stats: index: title: @@ -436,46 +810,214 @@ fr: index: not_hosted_notice: Gem non hébergé sur Rubygems. title: Toutes les versions de %{name} - versions_since: "%{count} versions depuis %{since}" + versions_since: + other: "%{count} versions depuis %{since}" + one: "%{count} version depuis %{since}" + imported_gem_version_notice: version: yanked: retiré - will_paginate: - next_label: Suivant - page_gap: "…" - previous_label: Précédent - page_entries_info: - multi_page: "%{model} de %{from} à %{to} parmi %{count} au total" - multi_page_html: "%{model} de %{from} à %{to} parmi %{count} au total" - single_page: - one: 1 %{model} affiché - other: Les %{count} %{model} affichés - zero: Aucun %{model} trouvé - single_page_html: - one: "1 %{model} affiché" - other: Les %{count} %{model} affichés - zero: Aucun %{model} trouvé - owners: - confirm: - confirmed_email: - token_expired: + adoptions: index: - add_owner: - name: - mfa: - status: - confirmed_at: - added_by: - action: - info: - email_field: - submit_button: - confirmed: - pending: - confirm_remove: - resend_confirmation: - resent_notice: + title: + subtitle_owner_html: + subtitle_user_html: + ownership_calls: + no_ownership_calls: + ownership_calls: + update: + success_notice: create: success_notice: - destroy: - removed_notice: - failed_notice: + index: + title: + subtitle_html: + share_requirements: + note_for_applicants: + created_by: + details: + apply: + close: + markup_supported_html: + create_call: + ownership_requests: + create: + success_notice: + update: + approved_notice: + closed_notice: + close: + success_notice: + ownership_requests: + note_for_owners: + your_ownership_requests: + close_all: + approve: + gems_published: + created_at: + no_ownership_requests: + create_req: + signin_to_create_html: + webauthn_credentials: + callback: + success: + recovery: + continue: + title: + notice_html: + saved: + webauthn_credential: + confirm_delete: + delete_failed: + delete: + confirm: + saved: + form: + new_device: + nickname: + submit: + oidc: + api_key_roles: + index: + api_key_roles: + new_role: + show: + api_key_role_name: + automate_gh_actions_publishing: + view_provider: + edit_role: + delete_role: + confirm_delete: + deleted_at_html: + edit: + edit_role: + git_hub_actions_workflow: + title: + configured_for_html: + to_automate_html: + not_github: + not_push: + a_gem: + instructions_html: + new: + title: + update: + success: + create: + success: + destroy: + success: + form: + add_condition: + remove_condition: + add_statement: + remove_statement: + deleted: + providers: + index: + title: + description_html: + show: + title: + id_tokens: + index: + title: + show: + title: + rubygem_trusted_publishers: + index: + title: + subtitle_owner_html: + delete: + create: + description_html: + destroy: + success: + create: + success: + new: + title: + subtitle_owner_html: + pending_trusted_publishers: + index: + title: + valid_for_html: + delete: + create: + description_html: + destroy: + success: + create: + success: + new: + title: + trusted_publisher: + unsupported_type: + github_actions: + repository_owner_help_html: + repository_name_help_html: + workflow_filename_help_html: + environment_help_html: + pending: + rubygem_name_help_html: + duration: + minutes: + other: + one: + hours: + other: + one: + days: + other: + one: + seconds: + other: + one: + form: + optional: + events: + table_component: + event: + time: + additional_info: + redacted: + no_user_agent_info: + rubygem_event: + version: + version_pushed: + version_yanked: + version_unyanked: + version_html: + version_pushed_sha256_html: + version_pushed_by_html: + version_yanked_by_html: + owner: + owner_added: + owner_added_owner_html: + owner_added_authorizer_html: + owner_removed: + owner_removed_owner_html: + owner_removed_by_html: + owner_confirmed: + user_event: + user: + created: + email: + login: + login_success: + webauthn_login: + mfa_method: + mfa_device: + none: + email: + email_verified: + email_sent_subject: + email_sent_from: + email_sent_to: + api_key: + api_key_created: + api_key_deleted: + api_key_name: + api_key_scopes: + api_key_gem_html: + api_key_mfa: + not_required: \ No newline at end of file diff --git a/config/locales/ja.yml b/config/locales/ja.yml index c50e66b94d2..93c1aa92fc4 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -1,32 +1,50 @@ +--- ja: + credentials_required: 認証情報が必要です + copied: コピー完了! + copy_to_clipboard: クリップボードにコピー edit: 編集 - failure_when_forbidden: URLを確認するか、再試行して下さい。 - feed_latest: RubyGems.org | 最新のGemの一覧 - feed_subscribed: RubyGems.org | 購読したGemの一覧 - footer_about_html: - footer_sponsors_html: - footer_join_rt_html: - form_disable_with: お待ち下さい... - invalid_page: + verification_expired: 検証が期限切れです。もう一度検証してください。 + feed_latest: RubyGems.org | 最新のgemの一覧 + feed_subscribed: RubyGems.org | 購読したgemの一覧 + footer_about_html: RubyGems.orgはRubyコミュニティのgemのホスティングサービスです。すぐにgemを公開してインストールできます。APIを使用して利用可能なgemの詳細を調べられます。ご自身が貢献者となりサイトをより良くしてください。 + footer_sponsors_html: RubyGems.orgは広範囲のRubyコミュニティとの連携を通じて成立しました。Fastlyはネットワーク帯域とCDNのサポートを提供しています。Ruby Centralはインフラの費用を捻出し、開発や運用の資金を募っています。出資者と出資の仕組みについてご確認ください。 + footer_join_rt_html: 全ての人にとって円滑にRubyGems.orgを稼動させ続けていく上で、開発者の時間を賄うためのご支援が必要です。今日からでもRuby Centralにご参加ください。 + form_disable_with: お待ちください…… + invalid_page: ページ番号が範囲外です。既定のページにリダイレクトしました locale_name: 日本語 none: なし - not_found: - api_key_forbidden: + not_found: 見つかりませんでした + forbidden: + api_gem_not_found: + api_key_forbidden: APIキーにアクセス権がありません + api_key_soft_deleted: + api_key_insufficient_scope: please_sign_up: アクセスが拒否されました。https://rubygems.org でアカウント登録を行ってください。 - please_sign_in: - otp_incorrect: - otp_missing: - sign_in: ログイン + please_sign_in: サインインしてお進みください。 + otp_incorrect: OTPのコードが正しくありません。ご確認の上再試行してください。 + otp_missing: 多要素認証が有効化済みですがOTPのコードが与えられませんでした。入力の上再試行してください。 + sign_in: サインイン sign_up: 新規登録 - dependency_list: - multifactor_authentication: - subtitle: コミュニティのGemホスティングサービス - this_rubygem_could_not_be_found: お探しのGemは見つかりませんでした。 + dependency_list: 全ての推移的な依存関係を表示 + multifactor_authentication: 多要素認証 + subtitle: コミュニティのgemホスティングサービス + this_rubygem_could_not_be_found: お探しのrubygemは見つかりませんでした。 time_ago: "%{duration}前" title: RubyGems.org update: 更新 try_again: エラーが発生しました。再試行してください。 advanced_search: 高度な検索 + authenticate: 認証 + helpers: + submit: + create: "%{model}を作成" + update: "%{model}を更新" activerecord: attributes: linkset: @@ -36,81 +54,170 @@ ja: docs: ドキュメントのURL mail: メーリングリストのURL wiki: WikiのURL - funding: + funding: 寄付のURL session: password: パスワード - who: Email又はユーザー名 + who: Eメールまたはユーザー名 user: avatar: アバター - email: Emailアドレス + email: Eメールアドレス + full_name: フルネーム handle: ユーザー名 password: パスワード + ownership/role: + owner: + admin: + maintainer: + api_key: + oidc_api_key_role: OIDC APIキーのロール + oidc/id_token: + jti: JWT ID + api_key_role: APIキーのロール + oidc/api_key_role: + api_key_permissions: APIキーのパーミッション + oidc/trusted_publisher/github_action: + repository_owner_id: GitHubリポジトリの所有者ID + oidc/pending_trusted_publisher: + rubygem_name: RubyGem名 errors: messages: - unpwn: - blocked: + unpwn: 過去にデータ侵害を受けたためお使いになれません + blocked: ドメイン %{domain} はスパムのため差し止められました。正しい個人のEメールを使ってください。 + models: + api_key: + attributes: + expires_at: + inclusion: 未来でなければなりません + organization: + attributes: + handle: + invalid: + ownership: + attributes: + user_id: + already_confirmed: は既にこのgemの所有者です + already_invited: は既にこのgemに招待されています + ownership_request: + attributes: + user_id: + taken: + existing: + user: + attributes: + handle: + invalid: + version: + attributes: + gem_full_name: + taken: "%{value}は既に存在します" + full_name: + taken: "%{value}は既に存在します" + oidc/rubygem_trusted_publisher: + attributes: + rubygem: + taken: この信頼できる発行元で既に構成されています + oidc/pending_trusted_publisher: + attributes: + rubygem_name: + unavailable: 既に使われています + models: + user: ユーザー + api_key: + zero: APIキー + one: APIキー + other: APIキー + activemodel: + attributes: + oidc/provider/configuration: + jwks_uri: JWKS URI + id_token_signing_alg_values_supported: サポート対象のIDトークン書名アルゴリズム + errors: + models: + oidc/api_key_permissions: + attributes: + valid_for: + inclusion: "%{value}秒は5分(300秒)から1日(86,400秒)までの間でなければなりません" + gems: + too_long: 最大1個のgemを含められます。 api_keys: + form: + exclusive_scopes: 除外するスコープ + rubygem_scope: gemのスコープ + rubygem_scope_info: このスコープはgemのプッシュ・ヤンクないし所有者の追加・削除のコマンドを特定のgemに制限します。 + multifactor_auth: 多要素認証 + enable_mfa: MFAを有効にする + expiration: 期限 create: - success: + success: 新しいAPIキーを作成しました + invalid_gem: 選択されたgemはこのキーのスコープにありません destroy: - success: + success: APIキーが正常に削除されました:%{name} index: - api_keys: - name: - scopes: - age: - last_access: - action: - delete: - confirm: - confirm_all: - new_key: - index_rubygems: - push_rubygem: - yank_rubygem: - add_owner: - remove_owner: - access_webhooks: - show_dashboard: - reset: - save_key: + api_keys: APIキー + name: 名前 + scopes: スコープ + gem: gem + age: 経過時間 + last_access: 直近のアクセス + action: 操作 + delete: 削除 + confirm: APIキーが失効します。よろしいですか。 + confirm_all: 全てのAPIキーが失効します。よろしいですか。 + new_key: 新しいAPIキー + index_rubygems: rubygemを一覧にする + push_rubygem: rubygemをプッシュする + yank_rubygem: rubygemをヤンクする + add_owner: 所有者を追加する + remove_owner: 所有者を削除する + update_owner: + access_webhooks: webhookにアクセスする + show_dashboard: ダッシュボードを表示 + configure_trusted_publishers: 信頼できる発行元を構成 + reset: リセット + save_key: キーを二度と表示できなくなるためご注意ください。新しいAPIキー: + mfa: MFA + expiration: 期限 new: - new_api_key: + new_api_key: 新しいAPIキー reset: - success: + success: 全てのAPIキーを削除しました update: - success: + success: APIキーが正常に更新されました + invalid_gem: 選択されたgemはこのキーのスコープにありません edit: - edit_api_key: - clearance_mailer: - change_password: - title: - subtitle: + edit_api_key: APIキーを編集する + invalid_key: 正しくないAPIキーは編集できません。削除して新規作成してください。 + all_gems: 全てのgem + gem_ownership_removed: このキーのスコープにあった%{rubygem_name}の所有権が削除されました。 dashboards: show: creating_link_text: 作成 - gem_link_text: Gemページ + gem_link_text: gemのページ latest: 最近の更新 latest_title: 最近の更新のRSSフィード - migrating_link_text: 統合 - mine: あなたのGem + migrating_link_text: 移行 + mine: 自分のgem my_subscriptions: 購読 - no_owned_html: あなたはまだGemを公開していません。よろしければGemの%{creating_link}又はRubyForgeからの%{migrating_link}ガイドをご覧ください。 - no_subscriptions_html: あなたはまだGemを購読していません。%{gem_link}から購読を行なってみてください。 - title: + no_owned_html: まだgemを公開していません。よろしければgemの%{creating_link}またはRubyForgeからの%{migrating_link}ガイドをご覧ください。 + no_subscriptions_html: まだgemを購読していません。%{gem_link}から何か購読してみてください。 + title: ダッシュボード + dependencies: + show: + click_to_expand: 矢印アイコンをクリックして展開 email_confirmations: create: - promise_resend: メールアドレスが登録されている場合には、アカウント有効化のための確認メールを送信しました。 + promise_resend: メールアドレスが登録されている場合は、アカウント有効化のための確認リンクをEメールで送ります。 new: - submit: 再送 + submit: 確認を再送 title: 確認メールを再送する - will_email_notice: アカウントを有効化するためのリンクを記載した確認メールを送信します。 + will_email_notice: アカウントを有効化するための確認リンクをメールで送ります。 update: - confirmed_email: あなたのメールアドレスの確認が完了しました。 + confirmed_email: メールアドレスが確認されました。 + token_failure: URLを見返すか、再度送信してみてください。 home: index: downloads_counting_html: ダウンロード数 - find_blurb: + find_blurb: RubyGemを発見、インストール、公開しよう。 learn: install_rubygems: RubyGemsをインストール layouts: @@ -119,230 +226,432 @@ ja: about: 概要 api: API blog: ブログ - contribute: 寄与 + contribute: 貢献 data: データ - designed_by: + designed_by: 設計 discussion_forum: 議論 - gems_served_by: + gems_served_by: gemの提供 guides: ガイド help: ヘルプ - hosted_by: - monitored_by: - optimized_by: - resolved_with: + hosted_by: ホスト + monitored_by: 監視 + optimized_by: 最適化 + resolved_with: DNS security: セキュリティ - source_code: ソースコード + source_code: コード statistics: 統計 - status: ステータス - supported_by: - tested_by: - tracking_by: - uptime: 可用性 + status: 状態 + supported_by: 協賛 + tested_by: テスト + tracking_by: 追跡 + uptime: 稼働時間 + verified_by: 検証 + secured_by: セキュリティ + looking_for_maintainers: メンテナの募集 header: dashboard: ダッシュボード - edit_profile: - settings: - search_gem_html: 検索… - sign_in: ログイン - sign_out: ログアウト + settings: 設定 + edit_profile: プロフィールを編集 + search_gem_html: gemを検索... + sign_in: サインイン + sign_out: サインアウト sign_up: 新規登録 + mfa_banner_html: "\U0001F389 セキュリティ機器に対応しました!新しい機器を設定してアカウントのセキュリティを向上しましょう。[詳細はこちら](link + to blog post)!" mailer: - confirm_your_email: あなたのメールアドレスに送信されたURLを確認してください。 - confirmation_subject: RubyGems.orgに登録されたあなたのメールアドレスを確認してください - link_expiration_explanation_html: + confirm_your_email: Eメールに送信されたリンクからEメールアドレスを確認してください。 + confirmation_subject: "%{host}に登録されたメールアドレスを確認してください" + link_expiration_explanation_html: このリンクは3時間のみ有効ですのでご留意ください。更新されたリンクの申請には確認メールの再送ページが使えます。 email_confirmation: - title: - subtitle: + title: Eメールの確認 + subtitle: あともう少しです! confirmation_link: メールアドレスを確認する - welcome_message: RubyGems.orgにようこそ! 以下のリンクをクリックしてあなたのメールアドレスを確認してください + welcome_message: "%{host}にようこそ! 以下のリンクからEメールを確認してください。" email_reset: - title: - subtitle: - visit_link_instructions: RubyGems.orgでのあなたのメールアドレスが変更されました。以下のURLからメールアドレスを確認してください。 + title: Eメールのリセット + subtitle: こんにちは、%{handle}! + visit_link_instructions: "%{host}でのEメールアドレスが変更されました。アカウントを再度有効にするには以下のURLに進んでください。" deletion_complete: - title: - subtitle: - subject: あなたのアカウントはrubygems.orgから削除されました - body_html: あなたのアカウント削除申請が処理されました。いつでも%{sign_up}ページから再登録をすることができます。 + title: 削除完了 + subtitle: さようなら! + subject: アカウントが%{host}から削除されました + body_html: "%{host}のアカウント削除申請が処理されました。%{sign_up}ページからいつでも再登録できます。" deletion_failed: - title: - subtitle: - subject: あなたのアカウントはrubygems.orgから削除されませんでした - body_html: あなたのrubygems.orgにおけるアカウント削除申請は処理されませんでした。申し訳ありませんが、現在アカウントを削除することができません。しばらく待ってから再度お試し頂くか、問題が継続するようならば%{contact}をお願いいたします。 + title: 削除失敗 + subtitle: すみません! + subject: "%{host}のアカウントの削除申請が失敗しました" + body_html: "%{host}におけるアカウント削除申請がありました。恐れ入りますが申請を処理できませんでした。しばらく待ってから再度お試しいただくか、問題が継続するようならば%{contact}をお願いいたします。" notifiers_changed: - subject: - title: - subtitle: - 'on': - off_html: + subject: "%{host}のEメール通知設定を変更しました" + title: Eメール通知 + subtitle: こんにちは、%{handle} + 'on': 入 + off_html: "" gem_pushed: - subject: - title: + subject: "%{gem} gemが%{host}にプッシュされした" + title: gemがプッシュされました gem_yanked: - subject: - title: + subject: "%{gem} gemが%{host}からヤンクされました" + title: gemがヤンクされました reset_api_key: - subject: - title: - subtitle: + subject: "%{host}のAPIキーがリセットされました" + title: APIキーがリセットされました + subtitle: こんにちは、%{handle} + webauthn_credential_created: + subject: "%{host}に新しいセキュリティ機器を追加しました" + title: セキュリティ機器が追加されました + subtitle: こんにちは、%{handle}! + webauthn_credential_removed: + subject: "%{host}のセキュリティ機器を削除しました" + title: セキュリティ機器が削除されました + subtitle: こんにちは、%{handle}! + totp_enabled: + subject: "%{host}で認証アプリを有効にしました" + title: 認証アプリが有効になりました + subtitle: こんにちは、%{handle}! + totp_disabled: + subject: "%{host}で認証アプリを無効にしました" + title: 認証アプリが無効になりました + subtitle: こんにちは、%{handle}! email_reset_update: - subject: - title: + subject: "%{host}にEメールアドレスの更新を申請しました" + title: Eメールの更新を申請しました ownership_confirmation: - subject: - title: - subtitle: - body_html: - link_expiration_explanation_html: + subject: "%{host}上で%{gem} gemの所有権を確定してください" + title: 所有権の確定 + subtitle: こんにちは、%{handle}! + body_text: "%{authorizer}により%{gem} gemの所有者として追加されました。所有権を確定するには以下のリンクに進んでください。" + body_html: %{authorizer}により%{gem} + gemの所有者として追加されました。所有権を確定するには以下のリンクをクリックしてください。 + link_expiration_explanation_html: なおこちらのリンクが有効なのはあと%{expiry_hours}です。サインインした後に%{gem} gemページから確認メールを再送できます。 owner_added: - subject_self: - subject_others: - title: - subtitle: - body_self_html: - body_others_html: + subject_self: "%{gem} gemの所有者として追加されました" + subject_others: ユーザー%{owner_handle}が%{gem} gemの所有者として追加されました + title: 所有者が追加されました + subtitle: こんにちは、%{user_handle}! + body_self_html: あなたは%{host}の%{gem} + gemの所有者として追加されました。 + body_others_html: %{owner_handle}%{authorizer}により%{gem} + gemの所有者として追加されました。この通知を受け取ったのは、%{gem}の所有者の1人だからです。 owner_removed: + subject: "%{gem} gemの所有者から削除されました" + title: 所有者が削除されました + subtitle: こんにちは、%{user_handle}! + body_html: あなた%{remover}によって%{host}上の%{gem} + gemの所有者から削除されました。 + owner_updated: subject: title: subtitle: body_html: + body_text: + ownerhip_request_closed: + title: 所有権の申請 + subtitle: こんにちは、%{handle}! + body_html: "%{gem}の所有権を申請いただきありがとうございます。心苦しいのですが所有権の申請はgemの所有者により却下されました。" + ownerhip_request_approved: + body_html: おめでとうございます!%{gem}の所有権の申請が許諾されました。gemの所有者として追加されました。 + new_ownership_requests: + body_html: + zero: "%{gem}には新しい所有権の申請がありません。" + one: "%{gem}には1件の新しい所有権の申請があります。申請を見るには以下のボタンをクリックしてください。" + other: "%{gem}には%{count}件の新しい所有権の申請があります。全ての申請を見るには以下のボタンをクリックしてください。" + button: 所有権の申請 + disable_notifications: これらのメッセージの受け取りを停止するには、こちらを更新してください: + owners_page: 所有権 + web_hook_deleted: + title: webhookが削除されました + subject: "%{host}のwebhookが削除されました" + subtitle: こんにちは、%{handle}! + body_text: "%{url}にPOSTするwebhookが%{failures}回の失敗の後に削除されました。" + body_html: %{url}POSTするwebhookが%{failures}回の失敗の後に削除されました。 + global_text: このwebhookは以前何らかのgemがプッシュされたときに呼ばれました。 + global_html: このwebhookは以前何らかのgemがプッシュされたときに呼ばれました。 + gem_text: このwebhookは以前%{gem}がプッシュされたときに呼ばれました。 + gem_html: このwebhookは以前%{gem}がプッシュされたときに呼ばれました。 + web_hook_disabled: + title: webhookが無効になりました + subject: "%{host}のwebhookを無効にしました" + subtitle: こんにちは、%{handle}! + body_text: | + %{url}にPOSTするwebhookが%{disabled_reason}のため無効になりました。 + 最後に成功したのは%{last_success}で、それから%{failures_since_last_success}回失敗しました。 + `%{delete_command}`を走らせてこのwebhookを削除されると良いでしょう。 + body_html: | +

          %{url}POSTするwebhookが%{disabled_reason}のため無効になりました。

          +

          最後に成功したのは%{last_success}で、それから%{failures_since_last_success}回失敗しました。

          +

          %{delete_command}を走らせてこのwebhookを削除されると良いでしょう。

          + global_text: このwebhookは以前何らかのgemがプッシュされたときに呼ばれました。 + global_html: このwebhookは以前何らかのgemがプッシュされたときに呼ばれました。 + gem_text: このwebhookは以前%{gem}がプッシュされたときに呼ばれました。 + gem_html: このwebhookは以前%{gem}がプッシュされたときに呼ばれました。 + gem_trusted_publisher_added: + title: 信頼できる発行元が追加されました + admin_manual: + title: news: show: - title: 新しくリリースされたGem - all_gems: 全体 - popular_gems: 人気 + title: 新しいリリース - 全てのgem + all_gems: 全てのgem + popular_gems: 人気のgem popular: - title: 新しくリリースされた人気のGem + title: 新しいリリース - 人気のgem pages: about: - founding_html: 2009年4月に%{founder}の手によって発足したプロジェクトは、現在%{contributors}の貢献を得て%{downloads}に到るまで成長を遂げました。RubyGems 1.3.6 リリースからは、サイト名称をGemcutterから%{title}に変更し、Rubyコミュニティの中におけるサイトの役割を明確化しました。 - support_html: Rubygems.orgは特定の企業によって運営されていないにも関わらず、私たちに多大な利益をもたらしています。現行のサイトのデザイン、イラストレーション、そしてフロントエンド開発は%{dockyard}により為されました。%{github}は私たちがコードを共有し協力するために必要不可欠なツールです。当初のサービスが稼働していた%{heroku}のサービスは、プロジェクトをコミュニティ全体が信頼できる持続性のあるものとしました。現在のサービスは%{aws}によりホストされています。 - technical_html: このサイトの一つの特徴、それは100% Rubyで構成されているということです。主要部分は%{rails}アプリケーションで、Gemは%{s3}上にホストされ%{fastly}によりサーブされており、 新しく公開したGemがインストール可能になるまでほんの数秒しか要しません。詳細はGitHub上の%{license}の%{source_code}をご確認ください。 + contributors_amount: "%{count}人以上のRubyist" + downloads_amount: 何億回ものgemダウンロード + checkout_code: コードをご確認ください + mit_licensed: MITライセンスの下に配布されています + logo_header: ロゴをお探しですか + logo_details: ダウンロードボタンを押すと、3つの.PNGと.SVGでRubyGemsのロゴが手に入ります。 + founding_html: 2009年4月に%{founder}の手によって発足したプロジェクトは、現在%{contributors}の貢献を得て%{downloads}に到るまで成長を遂げました。RubyGems + 1.3.6 リリースからは、サイト名称をGemcutterから%{title}に変更し、Rubyコミュニティの中におけるサイトの役割を明確化しました。 + support_html: Rubygems.orgは特定の企業によって運営されているわけではありませんが、これまで沢山の企業により助けられてきました。現行のサイトのデザイン、イラストレーション、そして本サイトのフロントエンド開発は%{dockyard}により為されました。%{github}も私たちがコードを共有し協力し合う上で助けになっており、掛け替えのないものです。当初のサービスが稼働していた%{heroku}の素晴しいサービスは、RubyGemsがコミュニティ全体から信頼される持続性のあるものであることを示しました。現在のインフラは%{aws}でホストされています。 + technical_html: このサイトの技術的な側面に目を向けるなら、100% Rubyでできています。サイトの主要な部分は%{rails}アプリケーションです。gemは%{s3}上にホストされ、%{fastly}によりサーブされており、新しく公開したgemがインストール可能になるまで通常はほんの数秒です。詳細は%{source_code}。GitHubで%{license}。 title: 概要 purpose: - better_api: Gemを取り扱うより良いAPIを提供すること + better_api: gemを取り扱う、より良いAPIを提供すること enable_community: コミュニティがサイトを改善できるようにすること - header: 'ようこそRubyコミュニティのGemホスティングサービス %{site}へ。このプロジェクトの目的は以下の3点です:' + header: ようこそRubyコミュニティのgemホスティングサービス %{site}へ。このプロジェクトの目的は以下の3点です: transparent_pages: 透過的でアクセスしやすいプロジェクトページを作成すること data: - title: + title: RubyGems.orgのデータのダンプ download: - title: + title: RubyGemsをダウンロード faq: - title: + title: FAQ migrate: - title: + title: gemを移行する security: - title: + title: セキュリティ sponsors: - title: + title: 出資者 + password_mailer: + change_password: + closing: こちらを申請しなかった場合はこのEメールはご放念ください。パスワードは変更されません。 + opening: あなただと良いのですが、誰かがあなたにパスワードを変更するリンクを送るよう申請しました。 + title: パスワードを変更する + subtitle: こんにちは、%{handle}! passwords: edit: submit: このパスワードを保存する - title: パスワードをリセットする + title: パスワードをリセット + token_failure: URLをダブルチェックするか、パスワードリセットを再度試してみてください。 new: - submit: パスワードをリセットする + submit: パスワードをリセット title: パスワードを変更する - will_email_notice: パスワード変更のためのメールが送信されます。 - otp_prompt: - authenticate: + will_email_notice: パスワード変更のためのリンクをEメールで送ります。 + create: + success: 数分でEメールが届きます。メールにはパスワードを変更する手順が記載されています。 + failure_on_missing_email: Eメールは空にできません。 + update: + failure: パスワードを変更できませんでした。もう一度お試しください。 multifactor_auths: - incorrect_otp: - otp_code: - require_mfa_disabled: - require_mfa_enabled: + session_expired: ログインページのセッションが期限切れになりました。 + require_mfa_enabled: 多要素認証が有効になっていません。最初に有効にしなくてはなりません。 + require_webauthn_enabled: 有効になっているセキュリティ機器がありません。最初に機器をアカウントに紐付けなければなりません。 + setup_required_html: アカウントとgemの保護のため、多要素認証の設定が必要です。詳細はブログ記事をお読みください。 + setup_recommended: アカウントとgemの保護のため、多要素認証を設定することを推奨します。今後アカウントにはMFAの有効化が必須になります。 + strong_mfa_level_required_html: アカウントとgemの保護のため、MFAの水準の「UIとgemのサインイン」または「UIとAPI」への変更が必要です。詳細についてはブログ記事をお読みください。 + strong_mfa_level_recommended: アカウントとgemの保護のため、MFAの水準を「UIとgemのサインイン」または「UIとAPI」に変更することをお勧めします。将来のアカウントはこれらの水準のどちらか1つにMFAが有効になっていることが必須になります。 + setup_webauthn_html: "\U0001F389 セキュリティ機器に対応しました!新しい機器を設定してアカウントのセキュリティを向上しましょう。詳細はこちら!" + api: + mfa_required: + mfa_required_not_yet_enabled: + mfa_required_weak_level_enabled: + mfa_recommended_not_yet_enabled: + mfa_recommended_weak_level_enabled: + recovery: + continue: 続ける + title: 復旧コード + saved: 復旧コードを保存したことを確認しました。 + confirm_dialog: + note_html: これらの復旧コードをコピー及び保存してください。認証機器を紛失した場合にこれらのコードを使ってログインしMFAをリセットできます。各コードは1回使えます。 + already_generated: 既に復旧コードを保存したはずです。 + update: + invalid_level: 不正なMFAの水準です。 + success: 多要素認証の水準が正常に更新されました。 + prompt: + webauthn_credential_note: Touch ID、YubiKeyなどのセキュリティ機器で認証してください。 + sign_in_with_webauthn_credential: セキュリティ機器で認証 + otp_code: OTPのコード + otp_or_recovery: OTPまたは復旧コード + recovery_code: 復旧コード + recovery_code_html: 多要素認証機器やセキュリティ機器へのアクセスを失った場合は、正しい復旧コードを使えます。 + security_device: セキュリティ機器 + verify_code: コードを検証 + totps: + incorrect_otp: OTPのコードが正しくありません。 + require_totp_disabled: OTPベースの多要素認証が既に有効になっています。OTPベースの認証を再構成するには最初に削除しなければなりません。 + require_totp_enabled: 有効になっている認証器がありません。最初に有効にしなければなりません。 new: - title: - scan_prompt: - otp_prompt: - confirm: - enable: - account: - key: - time_based: + title: 多要素認証を有効にする + scan_prompt: 認証アプリでQRコードを読み取ってください。コードを読み取れないようであれば手作業でアプリに以下の情報を追加してください。 + otp_prompt: 認証アプリ上の数値のコードを入力して続けてください。 + confirm: 復旧コードを安全に保持しました。 + enable: 有効 + account: アカウント:%{account} + key: キー:%{key} + time_based: 時間ベース:はい create: - qrcode_expired: - success: - recovery: - continue: - title: - note: + qrcode_expired: QRコードとキーが期限切れです。新しい機器を再度登録してみてください。 + success: OTPベースの多要素認証が正常に有効になりました。 destroy: - success: - update: - success: + success: OTPベースの多要素認証の有効化に失敗しました。 notifiers: update: - success: + success: Eメール通知設定が正常に更新されました。 show: - info: - 'on': - 'off': - recommended: + info: |- + 不正なgemや所有権の変更の検出を助けるため、所有するgemに関して新しいバージョンがプッシュ、ヤンク、または新しい所有者が追加されるたびに、電子メールが送信されます。 + これらのメールを受信して読むことで、Rubyのエコシステムの保護に役立ちます。 + 'on': 入 + 'off': 切 + recommended: 推奨 + title: Eメール通知 + update: 更新 + owner_heading: 所有権の通知 + owner_request_heading: 所有権の申請の通知 + push_heading: プッシュ通知 + webauthn_verifications: + expired_or_already_used: 使用されたリンク中のトークンは期限切れか既に使われています。 + no_port: ポートが与えられていません。再試行してください。 + pending: セキュリティ機器の認証が保留中です。 + prompt: + title: セキュリティ機器で認証 + authenticating_as: 認証対象 + authenticate: 認証 + no_webauthn_devices: 有効なセキュリティ機器がありません + successful_verification: + title: 成功しました! + close_browser: このブラウザを閉じてください。 + failed_verification: + title: エラー - 検証が失敗しました + close_browser: このブラウザを閉じて再試行してください。 + owners: + confirm: + confirmed_email: "%{gem} gemの所有者として追加されました" + token_expired: 確認トークンが期限切れです。gemのページからトークンを再送してみてください。 + index: + add_owner: 所有者を追加 + name: 所有者 + mfa: MFAの状態 + status: 状態 + confirmed_at: 確定日時 + added_by: 追加者 + action: 操作 + email_field: Eメール / ハンドル名 + submit_button: 所有者を追加 + info: 所有者を追加または削除する + confirmed: 確定 + pending: 保留 + confirm_remove: このユーザーを所有者から削除されたいとのことでよろしいですか。 + role: + role_field: + resend_confirmation: + resent_notice: 確定メールがEメールに再送されました。 + create: + success_notice: "%{handle}が未確定の所有者として追加されました。当該ユーザーがEメールに送られた確定メールでクリックした後に所有権アクセスが有効になります。" + destroy: + removed_notice: "%{owner_name}は所有者から正常に削除されました" + failed_notice: gemの唯一の所有者は削除できません。 + edit: title: - update: - push_heading: - owner_heading: + role: + update: + update_current_user_role: + success_notice: + mfa_required: gemでMFAの要件が有効化されているため、アカウントにMFAを設定してください。 settings: edit: - title: + title: 設定を編集 + webauthn_credentials: セキュリティ機器 + no_webauthn_credentials: セキュリティ機器がありません + webauthn_credential_note: セキュリティ機器とは、セキュリティキーや生体認証キーといった、FIDO2規格に準じた任意の機器です。 + otp_code: OTPコードまたは復旧コード api_access: - confirm_reset: この操作は取り消せませんが、本当によろしいですか? - credentials_html: 'もしコマンドラインから%{gem_commands_link}を使いたい場合、%{gem_credentials_file}ファイルが必要になります。このファイルは以下のコマンドで再生成することができます:' - key_is_html: あなたのAPIキーは%{key}です。 - link_text: Gemコマンド + confirm_reset: よろしいですか。この操作は取り消せません。 + credentials_html: もしコマンドラインから%{gem_commands_link}を使いたい場合、%{gem_credentials_file}ファイルが必要になります。このファイルは以下のコマンドで再生成できます: + key_is_html: APIキーは%{key}です。 + link_text: gemコマンド reset: APIキーをリセットする + reset_all: 全てのスコープにあるAPIキーを削除 title: APIアクセス reset_password: - title: パスワードのリセット + title: パスワードをリセット mfa: - multifactor_auth: - disabled: - go_settings: - enabled: - update: + multifactor_auth: 多要素認証 + otp: 認証アプリ + disabled_html: OTPベースの多要素認証を有効にしていません。MFAの水準に関する詳細情報についてはRubyGemsのMFAのガイドをご参照ください。 + go_settings: 新しい機器を登録 + level_html: 多要素認証が有効になっています。「更新」をクリックしてMFAの水準を変更してください。MFAの水準についての詳細情報はRubyGemsのMFAのガイドをご参照ください。 + enabled_note: 多要素認証が有効化済みです。認証器上のOTPか有効な復旧コードの1つを入力して無効にしてください。 + update: 更新 + disable: 無効 + enabled: 有効 + disabled: 無効 level: - title: - disabled: - ui_only: - ui_and_api: - ui_and_gem_signin: + title: MFAの水準 + disabled: 無効 + ui_only: UIのみ + ui_and_api: UIとAPI(推奨) + ui_and_gem_signin: UIとgemのサインイン profiles: + adoptions: + no_ownership_calls: 所有者の募集を作成しているgemがありません。 + no_ownership_requests: 所有権の申請をしていません。 + title: 引き継ぎ + subtitle_html: 新しい貢献者を募るか所有権を申請してください(詳細) edit: - change_avatar: - email_awaiting_confirmation: あなたの新しいメールアドレス%{unconfirmed_email}を確認してください。 - enter_password: パスワードを入力してください。 - hide_email: メールアドレスを公開しない - optional_twitter_username: Twitterのユーザー名(任意,公開) - title: プロフィールを編集する + change_avatar: アバターを変更 + disabled_avatar_html: プライベートEメール設定のため、既定のアバターが使われています。パーソナライズされたGravatarを有効にするには「公開プロフィールにEメールを表示」を有効にしてください。なおこのようにするとEメールが公開されます。 + email_awaiting_confirmation: 新しいEメールアドレス%{unconfirmed_email}を確認してください。 + enter_password: アカウントのパスワードを入力してください。 + optional_full_name: 省略可能です。公開されます。 + optional_twitter_username: Xのユーザー名。省略可能です。公開されます。 + twitter_username: ユーザー名 + title: プロフィールを編集 delete: - delete: アカウント削除 - delete_profile: 削除 - warning: アカウントの削除は不可逆的操作です。これはサポートに連絡しても取り消すことはできません。ご注意ください。 + delete: 削除 + delete_profile: プロフィールを削除 + warning: プロフィールの削除は不可逆的な操作です。サポートに連絡しても取り消せません。ご注意ください。 delete: - title: アカウント削除 - confirm: 確認 - instructions: お別れをしなければならないことが残念です...。パスワードを入力して操作を確定してください。 - list_only_owner_html: これらのGemはyanked状態になります。もしアカウントの削除前に別のオーナーを追加したい場合は、%{command_link}コマンドを用いてください。 - list_multi_owner: あなたはこれらのGemへのアクセス権を喪失しますが、他のオーナーがアクセス権を継続します。 - warning: アカウントの削除は不可逆的操作です。これはサポートに連絡しても取り消すことはできません。ご注意ください。 + title: プロフィールを削除 + confirm: 確定 + instructions: お別れをしなければならないことが残念です...。以下のフィールドにパスワードを入力して確定してください。 + list_only_owner_html: プロフィールを削除した場合、これらのgemはヤンクされた状態になります。もしプロフィールの削除前に別の所有者を追加したい場合は、%{command_link}コマンドが使えます。 + list_multi_owner: これらのgemへのアクセス権を喪失しますが、gemの他の所有者はアクセス権を持ち続けます。 + warning: プロフィールの削除は不可逆的な操作です。サポートに連絡しても取り消せません。ご注意ください。 rubygem: - owners_header: オーナー + owners_header: 所有者 destroy: - request_queued: あなたのアカウント削除処理は追って実行されます。削除処理の完了次第、確認のメールを送信します。 + request_queued: アカウント削除処理は追って実行されます。申請が処理されたら確認のメールを送信します。 update: - confirmation_mail_sent: 新しいメールアドレスを確認する手順を記載したメールを送信しました。 - updated: あなたのプロフィールはアップデートされました。 - request_denied: このリクエストは拒否されました。あなたのパスワードを認証することができませんでした。 + confirmation_mail_sent: 数分でEメールが届きます。メールには新しいEメールアドレスを確認する手順が含まれています。 + updated: プロフィールが更新されました。 + public_email: 公開プロフィールにメールアドレスを表示する + request_denied: このリクエストは拒否されました。パスワードを検証できませんでした。 + show: + title: "%{username} のプロフィール" + security_events: + title: セキュリティ事象 + description_html: |- + このページにはあなたのアカウントに起こったセキュリティ事象が表示されます。 + 何か疑わしい活動を見かけたら、サポートまでお問い合わせください。 rubygems: aside: - bundler_header: Gemfile - copied: コピー完了! - copy_to_clipboard: クリップボードにコピー - downloads_for_this_version: 現行バージョン - install: インストール - required_ruby_version: 必要Rubyバージョン - required_rubygems_version: 必要Gemバージョン + downloads_for_this_version: このバージョンのみ + gem_version_age: このバージョンがリリースされたのは + required_ruby_version: 必要なRubyのバージョン + required_rubygems_version: 必要なRubyGemsのバージョン + requires_mfa: 新しいバージョンはMFAを必要とします + released_with_mfa: MFAで投稿されたバージョン links: badge: バッジ bugs: バグトラッカー @@ -350,54 +659,77 @@ ja: code: ソースコード docs: ドキュメント download: ダウンロード - header: リンク集 + funding: 寄付 + header: リンク home: ホームページ mail: メーリングリスト report_abuse: 悪用報告 - reverse_dependencies: 被依存性 - review_changes: + reverse_dependencies: 被依存関係 + review_changes: 差分をレビュー rss: RSS subscribe: 購読 unsubscribe: 購読を解除 wiki: Wiki - ownership: - resend_ownership_confirmation: - blacklisted: - blacklisted_namespace: この名前はRubygems.orgにより保持されています。 + resend_ownership_confirmation: 確認を再送 + ownership: 所有者 + oidc: + api_key_role: + name: 'OIDC: %{name}' + new: 'OIDC: 作成' + trusted_publishers: 信頼できる発行元 + reserved: + reserved_namespace: この名前空間はrubygems.orgにより予約されています。 dependencies: - header: "%{title}依存性" + header: "%{title}依存関係" gem_members: authors_header: 作者 - not_using_mfa_warning_show: - not_using_mfa_warning_hide: - owners_header: オーナー - pushed_by: - using_mfa_info: - yanked_by: + self_no_mfa_warning_html: アカウントをセキュアに保つため、多要素認証 (MFA) + の有効化をご検討ください。 + not_using_mfa_warning_show: 多要素認証 (MFA) を使っていない所有者がいます。完全な一覧を見るにはクリックしてください。 + not_using_mfa_warning_hide: "* 以下の所有者は多要素認証 (MFA) を使っていません。クリックして隠します。" + owners_header: 所有者 + pushed_by: プッシュ者 + using_mfa_info: "* 全ての所有者が多要素認証 (MFA) を使っています。" + yanked_by: ヤンク者 sha_256_checksum: SHA 256チェックサム + signature_period: シグネチャの有効期限 + expired: 期限切れ + version_navigation: + previous_version: "←前のバージョン" + next_version: 次のバージョン→ index: - downloads: ダウンロード - title: Gems + downloads: ダウンロード数 + title: gem show: + bundler_header: Gemfile + install: インストール licenses_header: one: ライセンス other: ライセンス no_licenses: N/A requirements_header: 必須要件 - show_all_versions: 全てのバージョンを表示 (全%{count}項目) + show_all_versions: 全てのバージョンを表示(全%{count}件) versions_header: バージョン履歴 - yanked_notice: このバージョンはyanked状態としてマークされており、直接のダウンロードや依存性として利用できなくなっています。 + yanked_notice: このバージョンはヤンクされ、直接のダウンロードや依存関係になっている可能性がある他のgemは利用できません。 show_yanked: - not_hosted_notice: このGemはRubyGems.org上ではホストされていません。 + not_hosted_notice: このgemは現在RubyGems.org上ではホストされていません。このgemのヤンクされたバージョンはまだ存在する可能性があります。 reserved_namespace_html: - one: このGemは以前存在していましたが、オーナーが削除しました。RubyGems.orgチームはこの名前を1日間だけ確保しています。期間終了後、誰でもgem pushによりこの名前は利用可能となります。
          もしあなたが以前のオーナーならば、gem ownerコマンドを用いてオーナーを変更することができる他、新しいバージョンをgem pushを用いて公開することができます。 - other: このGemは以前存在していましたが、オーナーが削除しました。RubyGems.orgチームはこの名前を%{count}日間確保しています。期間終了後、誰でもgem pushによりこの名前は利用可能となります。
          もしあなたが以前のオーナーならば、gem ownerコマンドを用いてオーナーを変更することができる他、新しいバージョンをgem pushを用いて公開することができます。 + one: このgemは以前存在していましたが、所有者が削除しました。RubyGems.orgチームはこの名前を1日間だけ予約しています。期間終了後、誰でもgem + pushによりこの名前を要求できます。
          もしあなたが以前の所有者ならば、gem ownerコマンドを用いて所有者を変更できる他、新しいバージョンをgem + pushを用いて公開できます。 + other: このgemは以前存在していましたが、所有者が削除しました。RubyGems.orgチームはこの名前を%{count}日間予約しています。期間終了後、誰でもgem + pushによりこの名前を要求できます。
          もしあなたがこのgemの以前の所有者ならば、gem ownerコマンドを用いて所有者を変更できる他、新しいバージョンをgem + pushを用いて公開できます。 + security_events: + title: セキュリティ事象 + description_html: このページには%{gem}に起こったセキュリティ事象が表示されます。何か疑わしい活動が見られたときは、サポートまでお問い合わせください。 reverse_dependencies: index: - title: "%{name}の被依存性" - subtitle: + title: "%{name}の被依存関係" + subtitle: 以下のgemの最新版は%{name}を必要としています + no_reverse_dependencies: このgemは被依存関係がありません。 search: - search_reverse_dependencies_html: "被依存性の中から検索…" + search_reverse_dependencies_html: 被依存関係のgemから検索... searches: advanced: name: 名前 @@ -405,77 +737,263 @@ ja: description: 説明 downloads: ダウンロード数 updated: 更新日 + yanked: ヤンク済み show: - subtitle: '%{query}の検索結果' - month_update: - week_update: - filter: - yanked: - suggestion: + subtitle_html: "%{query}の検索結果" + month_update: 先月更新(%{count}件) + week_update: 先週更新(%{count}件) + filter: 絞り込み: + yanked: ヤンク済み(%{count}件) + suggestion: もしかして sessions: new: - forgot_password: パスワードをお忘れですか? - resend_confirmation: 確認メールが見当たりませんか? + forgot_password: パスワードをお忘れですか? + resend_confirmation: 確認メールを受け取っていませんか? verify: - title: - confirm: - notice: + title: パスワードを確認 + confirm: 確定 + notice: パスワードを確認してお進みください。 + create: + account_blocked: アカウントがrubygemsチームにより差し止められました。アカウントを復旧するにはsupport@rubygems.orgにEメールを送ってください。 stats: index: - title: + title: 統計 all_time_most_downloaded: 累計最多ダウンロード数 total_downloads: 累計ダウンロード数 - total_gems: Gem総数 + total_gems: gem総数 total_users: ユーザー総数 users: create: - email_sent: 確認メールがあなたのメールアドレスに送信されました。 + email_sent: 確認メールがEメールアドレスに送信されました。 new: - have_account: すでにアカウントをお持ちですか? + have_account: 既にアカウントをお持ちですか? versions: index: - not_hosted_notice: このGemはRubyGems.org上ではホストされていません。 - title: '%{name}の全バージョン履歴' - versions_since: "%{since}からの%{count}項目" + not_hosted_notice: このgemは現在RubyGems.org上ではホストされていません。 + title: "%{name}の全バージョン履歴" + versions_since: + other: "%{since}からの%{count}バージョン" + one: "%{since}からの1バージョン" + imported_gem_version_notice: このgemのバージョンは%{import_date}にRubyGems.orgにインポートされました。表示されている日付は作者によってgemspec中で指定されました。 version: - yanked: yanked - will_paginate: - next_label: 次 - page_gap: "…" - previous_label: 前 - page_entries_info: - multi_page: 全%{count}件中 %{from}件目から%{to}件目の%{model}を表示中 - multi_page_html: 全%{count}件中 %{from}件目から%{to}件目の%{model}を表示中 - single_page: - one: 全1件の%{model}を表示中 - other: 全%{count}件の%{model}を表示中 - zero: '%{model}は見つかりませんでした' - single_page_html: - one: 全1件の%{model}を表示中 - other: 全 %{count}件の%{model}を表示中 - zero: '%{model}は見つかりませんでした' - owners: - confirm: - confirmed_email: - token_expired: + yanked: ヤンク済み + adoptions: index: - add_owner: - name: - mfa: - status: - confirmed_at: - added_by: - action: - info: - email_field: - submit_button: - confirmed: - pending: - confirm_remove: - resend_confirmation: - resent_notice: + title: 引き継ぎ + subtitle_owner_html: '%{gem} に新しく参加するメンテナを呼び掛ける (詳細)' + subtitle_user_html: '%{gem}の所有権を申請する (詳細)' + ownership_calls: メンテナの募集 + no_ownership_calls: "%{gem}の所有権の募集はありません。gemの所有者は新しいメンテナを探していません。" + ownership_calls: + update: + success_notice: "%{gem}の所有権の募集は修了しました。" create: - success_notice: - destroy: - removed_notice: - failed_notice: + success_notice: "%{gem}への所有権の募集を作成します。" + index: + title: メンテナの募集 + subtitle_html: RubyGemsは新しく参加するメンテナを求めています(詳細) + share_requirements: 手を借りたい領域を共有してください + note_for_applicants: 応募者への補足: + created_by: 作成者 + details: 詳細 + apply: 申請 + close: 却下 + markup_supported_html: Rdocのマークアップに対応しています + create_call: 所有者の募集を作成 + ownership_requests: + create: + success_notice: 所有権の申請が提出されました。 + update: + approved_notice: 所有権の申請が承認されました。%{name}は所有者として追加されました。 + closed_notice: 所有権の申請が却下されました。 + close: + success_notice: "%{gem}への全ての未完了の所有権の申請は却下されました。" + ownership_requests: 所有権の申請 + note_for_owners: 所有者への補足: + your_ownership_requests: 所有権の申請 + close_all: 全て却下 + approve: 承認 + gems_published: gemが公開されました + created_at: 作成日 + no_ownership_requests: プロジェクトへの参加申請はこちらに表示されます。まだ%{gem}への所有権の申請はありません。 + create_req: 所有権の申請を作成 + signin_to_create_html: 所有権の申請を作成するにはサインインしてください。 + webauthn_credentials: + callback: + success: セキュリティ機器が正常に登録されました。 + recovery: + continue: 続ける + title: セキュリティ機器を正常に追加しました。 + notice_html: これらの復旧コードをコピー&ペーストしてください。セキュリティ機器を紛失した場合にこれらのコードを使ってログインできます。各コードは1度使えます。 + saved: 復旧コードを保存したことを確認しました。 + webauthn_credential: + confirm_delete: 認証情報が削除されました + delete_failed: 認証情報を削除できませんでした + delete: 削除 + confirm: この認証情報を削除してよろしいですか。 + saved: セキュリティ機器が正常に作成されました + form: + new_device: 新しいセキュリティ機器を登録 + nickname: ニックネーム + submit: 機器を登録 + oidc: + api_key_roles: + index: + api_key_roles: OIDC APIキーのロール + new_role: APIキーのロールを作成 + show: + api_key_role_name: APIキーのロール%{name} + automate_gh_actions_publishing: GitHub Actionsでgemの発表を自動化 + view_provider: プロバイダ%{issuer}を確認 + edit_role: APIキーのロールを編集 + delete_role: APIキーのロールを削除 + confirm_delete: このロールを削除してよろしいですか。 + deleted_at_html: このロールは%{time_html}前に削除され、使えなくなりました。 + edit: + edit_role: APIキーのロールを編集 + git_hub_actions_workflow: + title: OIDCでgemをプッシュするGitHub Actionsワークフロー + configured_for_html: このOIDC APIキーのロールはGitHub Actionsから%{link_html}へプッシュできるように構成されています。 + to_automate_html: 新しいタグがプッシュされたときの%{link_html}へのリリースを自動化するには、以下のワークフローをリポジトリに追加してください。 + not_github: このOIDC APIキーのロールはGitHub Actions用に構成されていません。 + not_push: このOIDC APIキーのロールはgemのプッシュができるように構成されていません。 + a_gem: gem + instructions_html: | + リリースするには、gemのバージョンを上げて(rake release:source_control_pushを使って)GitHubへ新しいタグをプッシュしてください。 + ワークフローは自動的にgemをビルドしてRubyGems.orgへプッシュします。 + new: + title: 新しいOIDC APIキーのロール + update: + success: OIDC APIキーのロールが更新されました + create: + success: OIDC APIキーのロールを作成しました + destroy: + success: OIDC APIキーのロールを削除しました + form: + add_condition: 条件を追加 + remove_condition: 条件を削除 + add_statement: ステートメントを追加 + remove_statement: ステートメントを削除 + deleted: ロールが削除されました。 + providers: + index: + title: OIDCプロバイダ + description_html: |- + 以下はRubyGems.orgに構成されたOIDCプロバイダです。
          + 別のOIDCプロバイダを追加する必要があるときはサポートにお問い合わせください。 + show: + title: OIDCプロバイダ + id_tokens: + index: + title: OIDC IDトークン + show: + title: OIDC IDトークン + rubygem_trusted_publishers: + index: + title: 信頼できる発行元 + subtitle_owner_html: "%{gem_html}の信頼できる発行元" + delete: 削除 + create: 作成 + description_html: | + 信頼できる発行元を使うと、長く残る機密な認証情報を保管することなくCIからgemをプッシュできます。 + 信頼できる発行元を設定する方法についての詳しい情報は、信頼できる発行元のドキュメントを参照してください。 + destroy: + success: 信頼できる発行元が削除されました + create: + success: 信頼できる発行元が作成されました + new: + title: 新しい信頼できる発行元 + subtitle_owner_html: "%{gem_html}に信頼できる発行元を追加" + pending_trusted_publishers: + index: + title: 待機中の信頼できる発行元 + valid_for_html: "%{time_html}まで有効" + delete: 削除 + create: 作成 + description_html: | + 待機中の信頼できる発行元を使うと、gemの最初のバージョンをプッシュする前に信頼できる発行元を構成できます。 + 信頼できる発行元を設定する方法についてより詳しくは、信頼できる発行元のドキュメントを参照してください。 + destroy: + success: 待機中の信頼できる発行元が削除されました + create: + success: 待機中の信頼できる発行元が作成されました + new: + title: 新しい待機中の信頼できる発行元 + trusted_publisher: + unsupported_type: サポートされていない信頼できる発行元の種類 + github_actions: + repository_owner_help_html: リポジトリを所有するGitHubの組織名またはGitHubのユーザー名 + repository_name_help_html: 公開ワークフローを含むGitHubリポジトリ名 + workflow_filename_help_html: |- + 公開ワークフローのファイル名。
          + このファイルは上で構成されたリポジトリの.github/workflows/ディレクトリになければなりません。 + environment_help_html: | + 上のワークフローが公開で使うGitHub Actionsの環境名。
          + これはリポジトリの設定で構成されていると良いでしょう。
          + 必須ではありませんが、個別の公開環境は強く推奨されます。 + 特にリポジトリに、コミットアクセスを持つがRubyGems.orgへのgemのプッシュアクセスを持つべきではないメンテナがいるときが該当します。 + pending: + rubygem_name_help_html: "(RubyGems.orgの)gemはこの発行元が使われたときに作られます" + duration: + minutes: + other: "%{count}分" + one: 1分 + hours: + other: "%{count}時間" + one: 1時間 + days: + other: "%{count}日" + one: 1日 + seconds: + other: "%{count}秒" + one: 1秒 + form: + optional: 省略できます + events: + table_component: + event: 事象 + time: 時刻 + additional_info: 追加情報 + redacted: 訂正 + no_user_agent_info: ユーザーエージェント情報なし + rubygem_event: + version: + version_pushed: バージョンがプッシュされました + version_yanked: バージョンがヤンクされました + version_unyanked: バージョンのヤンクが取り消されました + version_html: バージョン:%{version} + version_pushed_sha256_html: 'SHA256: %{sha256}' + version_pushed_by_html: プッシュ者:%{pusher} + version_yanked_by_html: ヤンク者:%{pusher} + owner: + owner_added: 所有者が追加されました + owner_added_owner_html: 新しく追加された所有者:%{owner} + owner_added_authorizer_html: 承認者:%{authorizer} + owner_removed: 所有者が削除されました + owner_removed_owner_html: 削除された所有者:%{owner} + owner_removed_by_html: 削除者:%{remover} + owner_confirmed: 所有者が確認されました + user_event: + user: + created: ユーザーが作成されました + email: Eメール:%{email} + login: + login_success: ログインが成功しました + webauthn_login: Webauthnのログイン:%{device} + mfa_method: MFAの方法:%{method} + mfa_device: MFA機器:%{device} + none: なし + email: + email_verified: Eメールが検証されました + email_sent_subject: 題名:%{subject} + email_sent_from: 差出人:%{from} + email_sent_to: 宛先:%{to} + api_key: + api_key_created: APIキーが作成されました + api_key_deleted: APIキーが削除されました + api_key_name: 名前:%{name} + api_key_scopes: スコープ:%{scopes} + api_key_gem_html: 'gem: %{gem}' + api_key_mfa: 'MFA: %{mfa}' + not_required: 必要ではありません \ No newline at end of file diff --git a/config/locales/nl.yml b/config/locales/nl.yml index e7480bb0628..936e48a5b04 100644 --- a/config/locales/nl.yml +++ b/config/locales/nl.yml @@ -1,9 +1,17 @@ +--- nl: + credentials_required: + copied: Gekopieerd + copy_to_clipboard: Kopieer naar klembord edit: Wijzig - failure_when_forbidden: Controleer het webadres, en probeer het opnieuw. + verification_expired: feed_latest: RubyGems.org | Nieuwste Gems feed_subscribed: RubyGems.org | Geabonneerde Gems - footer_about_html: RubyGems.org is de gem hosting service van de Ruby community. Publiceer en installeer je gems direct. Gebruik de API om meer informatie over beschikbare gems te vinden. Word een deelnemer en verbeter de site met jouw aanpassingen. + footer_about_html: RubyGems.org is de gem hosting service van de Ruby community. + Publiceer en installeer + je gems direct. Gebruik de API om meer informatie over + beschikbare gems te vinden. Word een deelnemer + en verbeter de site met jouw aanpassingen. footer_sponsors_html: footer_join_rt_html: form_disable_with: Een moment geduld... @@ -11,7 +19,11 @@ nl: locale_name: Nederlands none: Geen not_found: Niet gevonden + forbidden: + api_gem_not_found: api_key_forbidden: + api_key_soft_deleted: + api_key_insufficient_scope: please_sign_up: Toegang geweigerd. Schrijf je in voor een account op https://rubygems.org please_sign_in: otp_incorrect: @@ -27,12 +39,17 @@ nl: update: Wijzig try_again: Er is iets fout gegaan, probeer het opnieuw. advanced_search: Uitgebreid zoeken + authenticate: + helpers: + submit: + create: + update: activerecord: attributes: linkset: bugs: Bugtracker-URL changelog: Changelog - code: source code URL + code: Broncode URL docs: Documentatie-URL mail: Mailing-list-URL wiki: Wiki URL @@ -43,21 +60,102 @@ nl: user: avatar: Avatar email: E-mailadres + full_name: Voor-en achternaam handle: Gebruikersnaam password: Wachtwoord + ownership/role: + owner: + admin: + maintainer: + api_key: + oidc_api_key_role: + oidc/id_token: + jti: + api_key_role: + oidc/api_key_role: + api_key_permissions: + oidc/trusted_publisher/github_action: + repository_owner_id: + oidc/pending_trusted_publisher: + rubygem_name: errors: messages: unpwn: blocked: + models: + api_key: + attributes: + expires_at: + inclusion: + organization: + attributes: + handle: + invalid: + ownership: + attributes: + user_id: + already_confirmed: + already_invited: + ownership_request: + attributes: + user_id: + taken: + existing: + user: + attributes: + handle: + invalid: + version: + attributes: + gem_full_name: + taken: + full_name: + taken: + oidc/rubygem_trusted_publisher: + attributes: + rubygem: + taken: + oidc/pending_trusted_publisher: + attributes: + rubygem_name: + unavailable: + models: + user: + api_key: + zero: + one: + other: + activemodel: + attributes: + oidc/provider/configuration: + jwks_uri: + id_token_signing_alg_values_supported: + errors: + models: + oidc/api_key_permissions: + attributes: + valid_for: + inclusion: + gems: + too_long: api_keys: + form: + exclusive_scopes: + rubygem_scope: + rubygem_scope_info: + multifactor_auth: + enable_mfa: + expiration: create: success: + invalid_gem: destroy: success: index: api_keys: name: scopes: + gem: age: last_access: action: @@ -70,22 +168,26 @@ nl: yank_rubygem: add_owner: remove_owner: + update_owner: access_webhooks: show_dashboard: + configure_trusted_publishers: reset: save_key: + mfa: + expiration: new: new_api_key: reset: success: update: success: + invalid_gem: edit: edit_api_key: - clearance_mailer: - change_password: - title: - subtitle: + invalid_key: + all_gems: + gem_ownership_removed: dashboards: show: creating_link_text: aanmaken @@ -95,18 +197,26 @@ nl: migrating_link_text: migreren mine: Mijn Gems my_subscriptions: Mijn abonnementen - no_owned_html: Je hebt nog geen gems gepubliceerd. Bekijk de instructies voor het %{creating_link} van een gem of het  %{migrating_link} van een gem vanaf RubyForge. - no_subscriptions_html: Je bent nog niet geabonneerd op een of meerdere gems. Bezoek een %{gem_link} en abonneer je erop. + no_owned_html: Je hebt nog geen gems gepubliceerd. Bekijk de instructies voor + het %{creating_link} van een gem of het  %{migrating_link} van een gem vanaf + RubyForge. + no_subscriptions_html: Je bent nog niet geabonneerd op een of meerdere gems. + Bezoek een %{gem_link} en abonneer je erop. title: Dashboard + dependencies: + show: + click_to_expand: email_confirmations: create: - promise_resend: Als het opgegeven account bestaat, wordt er een activatielink naar je e-mail gestuurd. + promise_resend: Als het opgegeven account bestaat, wordt er een activatielink + naar je e-mail gestuurd. new: submit: Bevestiging opnieuw versturen title: Bevestigingsemail opnieuw sturen will_email_notice: We zullen je een nieuwe email sturen met een bevestigingslink update: confirmed_email: + token_failure: Controleer het webadres, en probeer het opnieuw. home: index: downloads_counting_html: @@ -138,14 +248,18 @@ nl: tested_by: Getest door tracking_by: Tracking uptime: Uptime + verified_by: + secured_by: + looking_for_maintainers: header: dashboard: Dashboard - edit_profile: settings: + edit_profile: search_gem_html: Gems Zoeken… sign_in: Inloggen sign_out: Uitloggen sign_up: Registreren + mfa_banner_html: mailer: confirm_your_email: confirmation_subject: @@ -185,6 +299,22 @@ nl: subject: title: subtitle: + webauthn_credential_created: + subject: + title: + subtitle: + webauthn_credential_removed: + subject: + title: + subtitle: + totp_enabled: + subject: + title: + subtitle: + totp_disabled: + subject: + title: + subtitle: email_reset_update: subject: title: @@ -192,6 +322,7 @@ nl: subject: title: subtitle: + body_text: body_html: link_expiration_explanation_html: owner_added: @@ -206,6 +337,50 @@ nl: title: subtitle: body_html: + owner_updated: + subject: + title: + subtitle: + body_html: + body_text: + ownerhip_request_closed: + title: + subtitle: + body_html: + ownerhip_request_approved: + body_html: + new_ownership_requests: + body_html: + zero: + one: + other: + button: + disable_notifications: + owners_page: + web_hook_deleted: + title: + subject: + subtitle: + body_text: + body_html: + global_text: + global_html: + gem_text: + gem_html: + web_hook_disabled: + title: + subject: + subtitle: + body_text: + body_html: + global_text: + global_html: + gem_text: + gem_html: + gem_trusted_publisher_added: + title: + admin_manual: + title: news: show: title: @@ -215,14 +390,36 @@ nl: title: pages: about: - founding_html: Het project is gestart in april 2009 door %{founder} en is sindsdien uitgegroeid tot een dienst met bijdragen van meer dan %{contributors} en %{downloads}. Sinds de RubyGems 1.3.6 release, is de site hernoemd van Gemcutter naar %{title} om de centrale rol in de Ruby community te verstevigen. - support_html: Hoewel rubygems.org niet wordt gerund door één specifiek bedrijf, zijn er vele die ons tot hier geholpen hebben. Het huidige design van de site is gesponsord door %{dockyard}. %{github} is van onschatbare waarde geweest om code te delen en er samen aan te werken. De site is begonnen op  %{heroku}, waarvan de geweldige service heeft geholpen om te bewijzen dat rubygems.org een oplossing is waar de hele community op kan bouwen. Our infrastructure is currently hosted on %{aws}. - technical_html: 'Een klein inkijkje in de technische aspecten van de site: Het is 100% Ruby. The main site is a %{rails}-applicatie. Gems worden gehost op %{s3}, served by %{fastly}, en de tijd tussen het publiceren van een nieuwe gem en het beschikbaar zijn voor installatie is minimaal. Voor meer informatie, %{source_code} op GitHub. De code is %{license}' + contributors_amount: + downloads_amount: + checkout_code: + mit_licensed: + logo_header: + logo_details: + founding_html: Het project is gestart in april 2009 door %{founder} en is sindsdien + uitgegroeid tot een dienst met bijdragen van meer dan %{contributors} en %{downloads}. + Sinds de RubyGems 1.3.6 release, is de site hernoemd van Gemcutter naar %{title} om + de centrale rol in de Ruby community te verstevigen. + support_html: Hoewel rubygems.org niet wordt gerund door één specifiek bedrijf, + zijn er vele die ons tot hier geholpen hebben. Het huidige design van de site + is gesponsord door %{dockyard}. %{github} is van onschatbare waarde geweest + om code te delen en er samen aan te werken. De site is begonnen op  %{heroku}, + waarvan de geweldige service heeft geholpen om te bewijzen dat rubygems.org + een oplossing is waar de hele community op kan bouwen. Our infrastructure + is currently hosted on %{aws}. + technical_html: 'Een klein inkijkje in de technische aspecten van de site: Het + is 100% Ruby. The main site is a %{rails}-applicatie. Gems worden gehost op %{s3}, + served by %{fastly}, en de tijd tussen het publiceren van een nieuwe gem en + het beschikbaar zijn voor installatie is minimaal. Voor meer informatie, %{source_code} op + GitHub. De code is %{license}' title: Over purpose: - better_api: Een betere API ter beschikking te stellen voor het beheren van gems. - enable_community: De community in staat te stellen de site te verbeteren en uit te breiden. - header: 'Welkom bij %{site}, dé hosting service voor de Ruby community. Het doel van dit poroject is drieledig:' + better_api: Een betere API ter beschikking te stellen voor het beheren van + gems. + enable_community: De community in staat te stellen de site te verbeteren en + uit te breiden. + header: 'Welkom bij %{site}, dé hosting service voor de Ruby community. Het + doel van dit poroject is drieledig:' transparent_pages: Het maken van meer transparante en toegankelijke projectpagina's. data: title: @@ -236,21 +433,64 @@ nl: title: sponsors: title: + password_mailer: + change_password: + closing: + opening: + title: + subtitle: passwords: edit: submit: Sla dit wachtwoord op title: reset wachtwoord + token_failure: Controleer het webadres, en probeer het opnieuw. new: submit: Wachtwoord wijzigen title: Wachtwoord wijzigen will_email_notice: We sturen een link om je wachtwoord te wijzigen. - otp_prompt: - authenticate: + create: + success: + failure_on_missing_email: + update: + failure: multifactor_auths: - incorrect_otp: - otp_code: - require_mfa_disabled: + session_expired: require_mfa_enabled: + require_webauthn_enabled: + setup_required_html: + setup_recommended: + strong_mfa_level_required_html: + strong_mfa_level_recommended: + setup_webauthn_html: + api: + mfa_required: + mfa_required_not_yet_enabled: + mfa_required_weak_level_enabled: + mfa_recommended_not_yet_enabled: + mfa_recommended_weak_level_enabled: + recovery: + continue: + title: + saved: + confirm_dialog: + note_html: + already_generated: + update: + invalid_level: + success: + prompt: + webauthn_credential_note: + sign_in_with_webauthn_credential: + otp_code: + otp_or_recovery: + recovery_code: + recovery_code_html: + security_device: + verify_code: + totps: + incorrect_otp: + require_totp_disabled: + require_totp_enabled: new: title: scan_prompt: @@ -263,14 +503,8 @@ nl: create: qrcode_expired: success: - recovery: - continue: - title: - note: destroy: success: - update: - success: notifiers: update: success: @@ -281,26 +515,88 @@ nl: recommended: title: update: - push_heading: owner_heading: + owner_request_heading: + push_heading: + webauthn_verifications: + expired_or_already_used: + no_port: + pending: + prompt: + title: + authenticating_as: + authenticate: + no_webauthn_devices: + successful_verification: + title: + close_browser: + failed_verification: + title: + close_browser: + owners: + confirm: + confirmed_email: + token_expired: + index: + add_owner: + name: + mfa: + status: + confirmed_at: + added_by: + action: + email_field: + submit_button: + info: + confirmed: + pending: + confirm_remove: + role: + role_field: + resend_confirmation: + resent_notice: + create: + success_notice: + destroy: + removed_notice: + failed_notice: + edit: + role: + title: + update: + success_notice: + update_current_user_role: + mfa_required: settings: edit: title: + webauthn_credentials: + no_webauthn_credentials: + webauthn_credential_note: + otp_code: api_access: confirm_reset: Weet je het zeker? Dit kan niet ongedaan worden gemaakt. - credentials_html: 'Als je de %{gem_commands_link} vanaf de command-line wilt gebruiken, heb je een %{gem_credentials_file} bestand nodig. Je kunt deze genereren met het volgende commando:' + credentials_html: 'Als je de %{gem_commands_link} vanaf de command-line wilt + gebruiken, heb je een %{gem_credentials_file} bestand nodig. Je kunt deze + genereren met het volgende commando:' key_is_html: Je API key is %{key} link_text: gem commando’s reset: Reset mijn API key + reset_all: title: API toegang reset_password: title: Reset wachtwoord mfa: multifactor_auth: - disabled: + otp: + disabled_html: go_settings: - enabled: + level_html: + enabled_note: update: + disable: + enabled: + disabled: level: title: disabled: @@ -308,12 +604,19 @@ nl: ui_and_api: ui_and_gem_signin: profiles: + adoptions: + no_ownership_calls: + no_ownership_requests: + title: + subtitle_html: edit: change_avatar: + disabled_avatar_html: email_awaiting_confirmation: enter_password: - hide_email: + optional_full_name: optional_twitter_username: + twitter_username: Gebruikersnaam title: Wijzig profiel delete: delete: @@ -333,23 +636,29 @@ nl: update: confirmation_mail_sent: updated: + public_email: request_denied: + show: + title: Profiel van %{username} + security_events: + title: + description_html: rubygems: aside: - bundler_header: Gemfile - copied: - copy_to_clipboard: downloads_for_this_version: Voor deze versie - install: Installeer + gem_version_age: Versie vrijgegeven required_ruby_version: Required Ruby Version required_rubygems_version: Required Rubygems Version + requires_mfa: + released_with_mfa: links: badge: Badge bugs: Bug-tracker changelog: - code: source code + code: Broncode docs: Documentatie download: Download + funding: header: Links home: Startpagina mail: Mailing-list @@ -360,25 +669,38 @@ nl: subscribe: unsubscribe: wiki: Wiki - ownership: resend_ownership_confirmation: - blacklisted: - blacklisted_namespace: + ownership: + oidc: + api_key_role: + name: + new: + trusted_publishers: + reserved: + reserved_namespace: dependencies: header: "%{title} afhankelijkheden" gem_members: authors_header: - owners_header: Eigenaren + self_no_mfa_warning_html: not_using_mfa_warning_show: not_using_mfa_warning_hide: + owners_header: Eigenaren pushed_by: using_mfa_info: yanked_by: sha_256_checksum: SHA 256 checksum + signature_period: + expired: + version_navigation: + previous_version: + next_version: index: downloads: Downloads title: alle gems show: + bundler_header: Gemfile + install: Installeer licenses_header: one: Licentie other: Licenties @@ -392,10 +714,14 @@ nl: reserved_namespace_html: one: other: + security_events: + title: + description_html: reverse_dependencies: index: title: subtitle: + no_reverse_dependencies: search: search_reverse_dependencies_html: searches: @@ -405,8 +731,9 @@ nl: description: downloads: updated: + yanked: show: - subtitle: voor %{query} + subtitle_html: voor %{query} month_update: week_update: filter: @@ -420,6 +747,8 @@ nl: title: confirm: notice: + create: + account_blocked: stats: index: title: @@ -436,46 +765,214 @@ nl: index: not_hosted_notice: Deze gem wordt momenteel niet gehost op rubygems.org title: Alle versies van %{name} - versions_since: "%{count} versies sinds %{since}" + versions_since: + other: "%{count} versies sinds %{since}" + one: "%{count} versie sinds %{since}" + imported_gem_version_notice: version: yanked: verwijderd - will_paginate: - next_label: Volgende - page_gap: "…" - previous_label: Vorige - page_entries_info: - multi_page: - multi_page_html: - single_page: - one: - other: - zero: - single_page_html: - one: - other: - zero: - owners: - confirm: - confirmed_email: - token_expired: + adoptions: index: - add_owner: - name: - mfa: - status: - confirmed_at: - added_by: - action: - info: - email_field: - submit_button: - confirmed: - pending: - confirm_remove: - resend_confirmation: - resent_notice: + title: + subtitle_owner_html: + subtitle_user_html: + ownership_calls: + no_ownership_calls: + ownership_calls: + update: + success_notice: create: success_notice: - destroy: - removed_notice: - failed_notice: + index: + title: + subtitle_html: + share_requirements: + note_for_applicants: + created_by: + details: + apply: + close: + markup_supported_html: + create_call: + ownership_requests: + create: + success_notice: + update: + approved_notice: + closed_notice: + close: + success_notice: + ownership_requests: + note_for_owners: + your_ownership_requests: + close_all: + approve: + gems_published: + created_at: + no_ownership_requests: + create_req: + signin_to_create_html: + webauthn_credentials: + callback: + success: + recovery: + continue: + title: + notice_html: + saved: + webauthn_credential: + confirm_delete: + delete_failed: + delete: + confirm: + saved: + form: + new_device: + nickname: + submit: + oidc: + api_key_roles: + index: + api_key_roles: + new_role: + show: + api_key_role_name: + automate_gh_actions_publishing: + view_provider: + edit_role: + delete_role: + confirm_delete: + deleted_at_html: + edit: + edit_role: + git_hub_actions_workflow: + title: + configured_for_html: + to_automate_html: + not_github: + not_push: + a_gem: + instructions_html: + new: + title: + update: + success: + create: + success: + destroy: + success: + form: + add_condition: + remove_condition: + add_statement: + remove_statement: + deleted: + providers: + index: + title: + description_html: + show: + title: + id_tokens: + index: + title: + show: + title: + rubygem_trusted_publishers: + index: + title: + subtitle_owner_html: + delete: + create: + description_html: + destroy: + success: + create: + success: + new: + title: + subtitle_owner_html: + pending_trusted_publishers: + index: + title: + valid_for_html: + delete: + create: + description_html: + destroy: + success: + create: + success: + new: + title: + trusted_publisher: + unsupported_type: + github_actions: + repository_owner_help_html: + repository_name_help_html: + workflow_filename_help_html: + environment_help_html: + pending: + rubygem_name_help_html: + duration: + minutes: + other: + one: + hours: + other: + one: + days: + other: + one: + seconds: + other: + one: + form: + optional: + events: + table_component: + event: + time: + additional_info: + redacted: + no_user_agent_info: + rubygem_event: + version: + version_pushed: + version_yanked: + version_unyanked: + version_html: + version_pushed_sha256_html: + version_pushed_by_html: + version_yanked_by_html: + owner: + owner_added: + owner_added_owner_html: + owner_added_authorizer_html: + owner_removed: + owner_removed_owner_html: + owner_removed_by_html: + owner_confirmed: + user_event: + user: + created: + email: + login: + login_success: + webauthn_login: + mfa_method: + mfa_device: + none: + email: + email_verified: + email_sent_subject: + email_sent_from: + email_sent_to: + api_key: + api_key_created: + api_key_deleted: + api_key_name: + api_key_scopes: + api_key_gem_html: + api_key_mfa: + not_required: \ No newline at end of file diff --git a/config/locales/pagy/nn.yml b/config/locales/pagy/nn.yml new file mode 100644 index 00000000000..9723695be1f --- /dev/null +++ b/config/locales/pagy/nn.yml @@ -0,0 +1,15 @@ +nn: + pagy: + item_name: + one: "resultat" + other: "resultat" + nav: + prev: "‹ Førre" + next: "Neste ›" + gap: "…" + info: + no_items: "Ingen %{item_name} funne" + single_page: "Viser %{count} %{item_name}" + multiple_pages: "Viser %{item_name} %{from}-%{to} av totalt %{count}" + combo_nav_js: "" + items_selector_js: "" diff --git a/config/locales/pagy/ro.yml b/config/locales/pagy/ro.yml new file mode 100644 index 00000000000..622aca36cac --- /dev/null +++ b/config/locales/pagy/ro.yml @@ -0,0 +1,17 @@ +# :one_other pluralization (see https://github.com/ddnexus/pagy/blob/master/lib/locales/utils/p11n.rb) + +ro: + pagy: + item_name: + one: "un record" + other: "recorduri" + nav: + prev: "‹ Înapoi" + next: "Înainte ›" + gap: "…" + info: + no_items: "Nici %{item_name} găsit" + single_page: "%{count} %{item_name}" + multiple_pages: "%{from}-%{to} din totalul de %{count}" + combo_nav_js: "" + items_selector_js: "" diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml index 3d758d85b68..a98589a5caa 100644 --- a/config/locales/pt-BR.yml +++ b/config/locales/pt-BR.yml @@ -1,32 +1,56 @@ +--- pt-BR: + credentials_required: + copied: Copiado + copy_to_clipboard: Copiar edit: Editar - failure_when_forbidden: Por favor, confira a URL ou tente submetê-la novamente. + verification_expired: feed_latest: RubyGems.org | Últimas Gems feed_subscribed: RubyGems.org | Gems do seu Feed - footer_about_html: RubyGems.org é o serviço de hospedagem de gems da comunidade Ruby. Publique e instale suas gems instantaneamente. Use a API para interagir e encontrar mais informações sobre gems disponíveis. Torne-se um contribuidor e melhore o site com suas mudanças. - footer_sponsors_html: - footer_join_rt_html: - form_disable_with: Atualizando... - invalid_page: Número da página está fora do limite. Redirecionado para página padrão. + footer_about_html: RubyGems.org é o serviço de hospedagem de gems da comunidade + Ruby. Publique e instale suas gems instantaneamente. Use a API para interagir + e encontrar mais informações sobre gems disponíveis. Torne-se um contribuidor + e melhore o site com suas mudanças. + footer_sponsors_html: O RubyGems.org só é possível através da nossa parceria com + a grande comunidade Ruby. Fastly fornece + uma largura de banda (bandwidth) e suporte a CDN, Ruby + Central cobre a infraestrutura e custos, e financia o desenvolvimento e o + trabalho operacional. Saiba mais sobre nossos parceiros + e como eles trabalham juntos. + footer_join_rt_html: Nós precisamos de sua ajuda para financiar os desenvolvedores + que mantém o RubyGems.org funcionando sem problemas. + Junte-se ao Ruby Central. + form_disable_with: Por favor, aguarde... + invalid_page: Esta página é inválida. Redirecionando para a página inicial... locale_name: Português do Brasil none: Nenhum not_found: Não encontrado + forbidden: + api_gem_not_found: api_key_forbidden: - please_sign_up: Accesso Negado. Por favor se registre em https://rubygems.org - please_sign_in: + api_key_soft_deleted: + api_key_insufficient_scope: + please_sign_up: Acesso Negado. Por favor se registre em https://rubygems.org + please_sign_in: Por favor, faça login em sua conta para continuar. otp_incorrect: Seu código OTP está incorreto. Por favor, verifique-o e tente novamente. - otp_missing: Você habilitou a autenticação multifator mas nenhum código OTP foi fornecido. Por favor, preencha-o e tente novamente. + otp_missing: Você habilitou a autenticação multifator mas nenhum código OTP foi + fornecido. Por favor, preencha-o e tente novamente. sign_in: Fazer Login sign_up: Cadastrar - dependency_list: + dependency_list: Mostrar todas as dependências multifactor_authentication: Autenticação Multifator - subtitle: o host de gems da sua comunidade - this_rubygem_could_not_be_found: A rubygem não foi encontrada. + subtitle: O host de gems da sua comunidade + this_rubygem_could_not_be_found: Não foi possível localizar esta rubygem time_ago: "%{duration} atrás" title: RubyGems.org update: Atualizar try_again: Algo deu errado. Por favor, tente novamente. advanced_search: Busca avançada + authenticate: + helpers: + submit: + create: + update: activerecord: attributes: linkset: @@ -43,21 +67,102 @@ pt-BR: user: avatar: Avatar email: Email + full_name: Nome completo handle: Usuário password: Senha + ownership/role: + owner: + admin: + maintainer: + api_key: + oidc_api_key_role: + oidc/id_token: + jti: + api_key_role: + oidc/api_key_role: + api_key_permissions: + oidc/trusted_publisher/github_action: + repository_owner_id: + oidc/pending_trusted_publisher: + rubygem_name: errors: messages: - unpwn: + unpwn: já apareceu anteriormente em um vazamento de dados e não deve ser utilizada blocked: + models: + api_key: + attributes: + expires_at: + inclusion: + organization: + attributes: + handle: + invalid: + ownership: + attributes: + user_id: + already_confirmed: + already_invited: + ownership_request: + attributes: + user_id: + taken: + existing: + user: + attributes: + handle: + invalid: + version: + attributes: + gem_full_name: + taken: + full_name: + taken: + oidc/rubygem_trusted_publisher: + attributes: + rubygem: + taken: + oidc/pending_trusted_publisher: + attributes: + rubygem_name: + unavailable: + models: + user: Usuário + api_key: + zero: + one: + other: + activemodel: + attributes: + oidc/provider/configuration: + jwks_uri: + id_token_signing_alg_values_supported: + errors: + models: + oidc/api_key_permissions: + attributes: + valid_for: + inclusion: + gems: + too_long: api_keys: + form: + exclusive_scopes: + rubygem_scope: + rubygem_scope_info: + multifactor_auth: + enable_mfa: + expiration: create: success: + invalid_gem: destroy: success: index: api_keys: name: scopes: + gem: age: last_access: action: @@ -70,34 +175,43 @@ pt-BR: yank_rubygem: add_owner: remove_owner: + update_owner: access_webhooks: show_dashboard: + configure_trusted_publishers: reset: save_key: + mfa: + expiration: new: new_api_key: reset: success: update: success: + invalid_gem: edit: edit_api_key: - clearance_mailer: - change_password: - title: - subtitle: + invalid_key: + all_gems: + gem_ownership_removed: dashboards: show: - creating_link_text: criar + creating_link_text: Criar gem_link_text: Página da gem latest: Últimas Atualizações latest_title: Últimas Atualizações | Feed RSS - migrating_link_text: migrar + migrating_link_text: Migrar mine: Minhas Gems my_subscriptions: Observando - no_owned_html: Você ainda não enviou nenhuma gem. Verifique os guias sobre %{creating_link} uma gem ou %{migrating_link} uma gem do RubyForge. - no_subscriptions_html: Você ainda não está observando nenhuma gem. Visite uma %{gem_link} para poder observá-la! + no_owned_html: Você ainda não enviou nenhuma gem. Verifique os guias sobre %{creating_link} + uma gem ou %{migrating_link} uma gem do RubyForge. + no_subscriptions_html: Você ainda não está observando nenhuma gem. Visite uma + %{gem_link} para poder observá-la! title: Painel de Controle + dependencies: + show: + click_to_expand: email_confirmations: create: promise_resend: @@ -107,6 +221,7 @@ pt-BR: will_email_notice: update: confirmed_email: + token_failure: Por favor, confira a URL ou tente submetê-la novamente. home: index: downloads_counting_html: downloads @@ -138,32 +253,43 @@ pt-BR: tested_by: Testado por tracking_by: Coleta de Dados uptime: Uptime + verified_by: + secured_by: + looking_for_maintainers: header: dashboard: Painel de Controle - edit_profile: - settings: + settings: Configurações da Conta + edit_profile: Editar Perfil search_gem_html: Buscar Gems… sign_in: Fazer Login sign_out: Sair sign_up: Cadastrar + mfa_banner_html: mailer: - confirm_your_email: - confirmation_subject: - link_expiration_explanation_html: + confirm_your_email: Por favor, confirme seu endereço de email através do link + que enviamos para o seu endereço de email. + confirmation_subject: Por favor, confirme seu endereço de email para se cadastrar + no RubyGems.org + link_expiration_explanation_html: Lembre-se de que este link é válido apenas por + 3 horas. Você pode gerar um novo link atualizado na página reenviar + email de confirmação. email_confirmation: - title: - subtitle: - confirmation_link: - welcome_message: + title: EMAIL DE CONFIRMAÇÃO + subtitle: Quase pronto! + confirmation_link: Confirme o endereço de email + welcome_message: Bem-vindo ao RubyGems.org! Visite o link abaixo para confirmar + seu endereço de email. email_reset: - title: - subtitle: - visit_link_instructions: + title: RESETAR ENDEREÇO DE EMAIL + subtitle: Olá, %{handle}! + visit_link_instructions: Você alterou seu endereço de email do RubyGems.org. + Por favor acesse a seguinte url para reativar sua conta. deletion_complete: - title: - subtitle: - subject: - body_html: + title: EXCLUSÃO COMPLETA + subtitle: Tchau! + subject: Sua conta do rubygems.org foi deletada com sucesso. + body_html: Sua solicitação de exclusão da sua conta do rubygems.org foi concluída. + Você pode criar outra na página %{sign_up}. deletion_failed: title: subtitle: @@ -185,6 +311,22 @@ pt-BR: subject: title: subtitle: + webauthn_credential_created: + subject: + title: + subtitle: + webauthn_credential_removed: + subject: + title: + subtitle: + totp_enabled: + subject: + title: + subtitle: + totp_disabled: + subject: + title: + subtitle: email_reset_update: subject: title: @@ -192,6 +334,7 @@ pt-BR: subject: title: subtitle: + body_text: body_html: link_expiration_explanation_html: owner_added: @@ -206,6 +349,50 @@ pt-BR: title: subtitle: body_html: + owner_updated: + subject: + title: + subtitle: + body_html: + body_text: + ownerhip_request_closed: + title: + subtitle: + body_html: + ownerhip_request_approved: + body_html: + new_ownership_requests: + body_html: + zero: + one: + other: + button: + disable_notifications: + owners_page: + web_hook_deleted: + title: + subject: + subtitle: + body_text: + body_html: + global_text: + global_html: + gem_text: + gem_html: + web_hook_disabled: + title: + subject: + subtitle: + body_text: + body_html: + global_text: + global_html: + gem_text: + gem_html: + gem_trusted_publisher_added: + title: + admin_manual: + title: news: show: title: Novos Releases - Todas as Gems @@ -215,14 +402,35 @@ pt-BR: title: Novos Releases - Gems Populares pages: about: - founding_html: Este projeto foi iniciado em Abril de 2009 por %{founder}, e desde então tem crescido para incluir as contribuições de %{contributors} e %{downloads}. A partir do lançamento do RubyGems 1.3.6 o site foi renomeado de Gemcutter para %{title} para solidificar o papel centra do site na comunidade Ruby. - support_html: Pelo Gemcutter não ser mantido por uma empresa específica, o projeto contou e conta com a contribuição de muitas pessoas. O design atual, ilustrações e o frontend do site foram criados pela %{dockyard}. Nós também contamos com ajuda do %{github} por nos ajudar a compartilhar código facilmente. Este site foi inicialmente hospedado no %{heroku}, que nos ajudaram a validar o Gemcutter como uma solução viável para toda a comunidade Ruby. Our infrastructure is currently hosted on %{aws}. - technical_html: 'Alguns aspectos técnicos sobre este site: 100% Ruby. A parte principal é um aplicativo %{rails}. As gems são hospedadas no %{s3}, served by %{fastly}, e o tempo de espera entre publicar uma nova gem e disponibilidade para instalação é mínimo. Para mais informações, verifique o %{source_code}, com a licença %{license} no GitHub.' + contributors_amount: + downloads_amount: + checkout_code: + mit_licensed: + logo_header: + logo_details: + founding_html: Este projeto foi iniciado em Abril de 2009 por %{founder}, e + desde então tem crescido para incluir as contribuições de %{contributors} + e %{downloads}. A partir do lançamento do RubyGems 1.3.6 o site foi renomeado + de Gemcutter para %{title} para solidificar o papel centra do site na comunidade + Ruby. + support_html: Pelo Gemcutter não ser mantido por uma empresa específica, o projeto + contou e conta com a contribuição de muitas pessoas. O design atual, ilustrações + e o frontend do site foram criados pela %{dockyard}. Nós também contamos com + ajuda do %{github} por nos ajudar a compartilhar código facilmente. Este site + foi inicialmente hospedado no %{heroku}, que nos ajudaram a validar o Gemcutter + como uma solução viável para toda a comunidade Ruby. Nossa infraestrutura + é atualmente hospedada no %{aws}. + technical_html: 'Alguns aspectos técnicos sobre este site: 100% Ruby. A parte + principal é um aplicativo %{rails}. As gems são hospedadas no %{s3}, servido + por %{fastly}, e o tempo de espera entre publicar uma nova gem e disponibilidade + para instalação é mínimo. Para mais informações, verifique o %{source_code}, + com a licença %{license} no GitHub.' title: Sobre o Rubygems purpose: better_api: Prover uma API melhor para lidar com gems enable_community: Empoderar a comunidade para melhorar o site - header: 'Bem vindo ao %{site}, o serviço de hospedagem de gems da comunidade Ruby. Este projeto tem três propósitos:' + header: 'Bem vindo ao %{site}, o serviço de hospedagem de gems da comunidade + Ruby. Este projeto tem três propósitos:' transparent_pages: Criar páginas mais acessíveis e transparentes para os projetos data: title: @@ -236,21 +444,64 @@ pt-BR: title: sponsors: title: + password_mailer: + change_password: + closing: + opening: + title: + subtitle: passwords: edit: submit: Salvar senha title: Resetar senha + token_failure: Por favor, confira a URL ou tente submetê-la novamente. new: submit: Resetar senha title: Alterar senha will_email_notice: Você vai receber um email com o link para atualizar sua senha. - otp_prompt: - authenticate: + create: + success: + failure_on_missing_email: + update: + failure: multifactor_auths: - incorrect_otp: - otp_code: - require_mfa_disabled: + session_expired: require_mfa_enabled: + require_webauthn_enabled: + setup_required_html: + setup_recommended: + strong_mfa_level_required_html: + strong_mfa_level_recommended: + setup_webauthn_html: + api: + mfa_required: + mfa_required_not_yet_enabled: + mfa_required_weak_level_enabled: + mfa_recommended_not_yet_enabled: + mfa_recommended_weak_level_enabled: + recovery: + continue: + title: + saved: + confirm_dialog: + note_html: + already_generated: + update: + invalid_level: + success: + prompt: + webauthn_credential_note: + sign_in_with_webauthn_credential: + otp_code: + otp_or_recovery: + recovery_code: + recovery_code_html: + security_device: + verify_code: + totps: + incorrect_otp: + require_totp_disabled: + require_totp_enabled: new: title: scan_prompt: @@ -263,14 +514,8 @@ pt-BR: create: qrcode_expired: success: - recovery: - continue: - title: - note: destroy: success: - update: - success: notifiers: update: success: @@ -281,26 +526,88 @@ pt-BR: recommended: title: update: - push_heading: owner_heading: + owner_request_heading: + push_heading: + webauthn_verifications: + expired_or_already_used: + no_port: + pending: + prompt: + title: + authenticating_as: + authenticate: + no_webauthn_devices: + successful_verification: + title: + close_browser: + failed_verification: + title: + close_browser: + owners: + confirm: + confirmed_email: + token_expired: + index: + add_owner: + name: + mfa: + status: + confirmed_at: + added_by: + action: + email_field: + submit_button: + info: + confirmed: + pending: + confirm_remove: + role: + role_field: + resend_confirmation: + resent_notice: + create: + success_notice: + destroy: + removed_notice: + failed_notice: + edit: + title: + role: + update: + update_current_user_role: + success_notice: + mfa_required: settings: edit: title: + webauthn_credentials: + no_webauthn_credentials: + webauthn_credential_note: + otp_code: api_access: confirm_reset: Tem certeza? - credentials_html: 'Se você quiser usar %{gem_commands_link} pela linha comando, você vai precisar de um arquivo %{gem_credentials_file}. Para gerar este arquivo rode o seguinte comando:' + credentials_html: 'Se você quiser usar %{gem_commands_link} pela linha comando, + você vai precisar de um arquivo %{gem_credentials_file}. Para gerar este + arquivo rode o seguinte comando:' key_is_html: A chave da sua API é %{key}. link_text: gems reset: Resetar minha chave da API + reset_all: title: Acesso à API reset_password: title: Resetar senha mfa: multifactor_auth: - disabled: + otp: + disabled_html: go_settings: - enabled: + level_html: + enabled_note: update: + disable: + enabled: + disabled: level: title: disabled: @@ -308,12 +615,19 @@ pt-BR: ui_and_api: ui_and_gem_signin: profiles: + adoptions: + no_ownership_calls: + no_ownership_requests: + title: + subtitle_html: edit: - change_avatar: + change_avatar: Mudar foto de perfil + disabled_avatar_html: email_awaiting_confirmation: enter_password: - hide_email: Não mostrar meu email + optional_full_name: optional_twitter_username: + twitter_username: Usuário title: Editar Perfil delete: delete: @@ -333,16 +647,21 @@ pt-BR: update: confirmation_mail_sent: updated: + public_email: Mostrar meu email request_denied: + show: + title: Perfil de %{username} + security_events: + title: + description_html: rubygems: aside: - bundler_header: Gemfile - copied: - copy_to_clipboard: downloads_for_this_version: Desta versão - install: instalar + gem_version_age: Versão lançada required_ruby_version: Versão Requerida do Ruby required_rubygems_version: Versão Requerida do RubyGems + requires_mfa: + released_with_mfa: links: badge: Badge bugs: @@ -350,6 +669,7 @@ pt-BR: code: Código Fonte docs: Documentação download: Download + funding: header: Links home: Homepage mail: Lista de Emails @@ -360,25 +680,40 @@ pt-BR: subscribe: Inscrever-se unsubscribe: Desinscrever-se wiki: Wiki - ownership: resend_ownership_confirmation: - blacklisted: - blacklisted_namespace: This namespace is reserved by rubygems.org. + ownership: + oidc: + api_key_role: + name: + new: + trusted_publishers: + reserved: + reserved_namespace: This namespace is reserved by rubygems.org. dependencies: header: gem_members: authors_header: Autores - not_using_mfa_warning_show: "* Alguns donos não estão usando MFA. Clique para a lista completa." - not_using_mfa_warning_hide: "* Os seguintes donos não estão usando MFA. Clique para esconder." + self_no_mfa_warning_html: + not_using_mfa_warning_show: "* Alguns donos não estão usando MFA. Clique para + a lista completa." + not_using_mfa_warning_hide: "* Os seguintes donos não estão usando MFA. Clique + para esconder." owners_header: Donos pushed_by: using_mfa_info: "* Todos os donos estão usando MFA." yanked_by: sha_256_checksum: SHA 256 checksum + signature_period: + expired: + version_navigation: + previous_version: + next_version: index: downloads: Downloads title: Gems show: + bundler_header: Gemfile + install: instalar licenses_header: one: Licença other: Licenças @@ -390,14 +725,28 @@ pt-BR: show_yanked: not_hosted_notice: Esta gem não está hospedada no Gemcutter. reserved_namespace_html: - one: This gem previously existed, but has been removed by its owner. The RubyGems.org team has reserved this gem name for 1 more day. After that time is up, anyone will be able to claim this gem name using gem push.
          If you are the previous owner of this gem, you can change ownership of this gem using the gem owner command. You can also create new versions of this gem using gem push. - other: This gem previously existed, but has been removed by its owner. The RubyGems.org team has reserved this gem name for %{count} more days. After that time is up, anyone will be able to claim this gem name using gem push.
          If you are the previous owner of this gem, you can change ownership of this gem using the gem owner command. You can also create new versions of this gem using gem push. + one: This gem previously existed, but has been removed by its owner. The RubyGems.org + team has reserved this gem name for 1 more day. After that time is up, anyone + will be able to claim this gem name using gem push.
          If you are the + previous owner of this gem, you can change ownership of this gem using the + gem owner command. You can also create new versions of this gem using gem + push. + other: This gem previously existed, but has been removed by its owner. The + RubyGems.org team has reserved this gem name for %{count} more days. After + that time is up, anyone will be able to claim this gem name using gem push. +
          If you are the previous owner of this gem, you can change ownership + of this gem using the gem owner command. You can also create new versions + of this gem using gem push. + security_events: + title: + description_html: reverse_dependencies: index: - title: "Dependências Reversas para %{name}" + title: Dependências Reversas para %{name} subtitle: + no_reverse_dependencies: search: - search_reverse_dependencies_html: "Buscar Gems com dependências…" + search_reverse_dependencies_html: Buscar Gems com dependências… searches: advanced: name: @@ -405,8 +754,9 @@ pt-BR: description: downloads: updated: + yanked: show: - subtitle: para %{query} + subtitle_html: para %{query} month_update: week_update: filter: @@ -420,6 +770,8 @@ pt-BR: title: confirm: notice: + create: + account_blocked: stats: index: title: Estatísticas @@ -436,46 +788,214 @@ pt-BR: index: not_hosted_notice: Esta gem não está hospdada no Gemcutter. title: Todas as versões para %{name} - versions_since: "%{count} versões desde %{since}" + versions_since: + other: "%{count} versões desde %{since}" + one: "%{count} versão desde %{since}" + imported_gem_version_notice: version: yanked: removida - will_paginate: - next_label: - page_gap: "…" - previous_label: - page_entries_info: - multi_page: - multi_page_html: - single_page: - one: - other: - zero: - single_page_html: - one: - other: - zero: - owners: - confirm: - confirmed_email: - token_expired: + adoptions: index: - add_owner: - name: - mfa: - status: - confirmed_at: - added_by: - action: - info: - email_field: - submit_button: - confirmed: - pending: - confirm_remove: - resend_confirmation: - resent_notice: + title: + subtitle_owner_html: + subtitle_user_html: + ownership_calls: + no_ownership_calls: + ownership_calls: + update: + success_notice: create: success_notice: - destroy: - removed_notice: - failed_notice: + index: + title: + subtitle_html: + share_requirements: + note_for_applicants: + created_by: + details: + apply: + close: + markup_supported_html: + create_call: + ownership_requests: + create: + success_notice: + update: + approved_notice: + closed_notice: + close: + success_notice: + ownership_requests: + note_for_owners: + your_ownership_requests: + close_all: + approve: + gems_published: + created_at: + no_ownership_requests: + create_req: + signin_to_create_html: + webauthn_credentials: + callback: + success: + recovery: + continue: + title: + notice_html: + saved: + webauthn_credential: + confirm_delete: + delete_failed: + delete: + confirm: + saved: + form: + new_device: + nickname: + submit: + oidc: + api_key_roles: + index: + api_key_roles: + new_role: + show: + api_key_role_name: + automate_gh_actions_publishing: + view_provider: + edit_role: + delete_role: + confirm_delete: + deleted_at_html: + edit: + edit_role: + git_hub_actions_workflow: + title: + configured_for_html: + to_automate_html: + not_github: + not_push: + a_gem: + instructions_html: + new: + title: + update: + success: + create: + success: + destroy: + success: + form: + add_condition: + remove_condition: + add_statement: + remove_statement: + deleted: + providers: + index: + title: + description_html: + show: + title: + id_tokens: + index: + title: + show: + title: + rubygem_trusted_publishers: + index: + title: + subtitle_owner_html: + delete: + create: + description_html: + destroy: + success: + create: + success: + new: + title: + subtitle_owner_html: + pending_trusted_publishers: + index: + title: + valid_for_html: + delete: + create: + description_html: + destroy: + success: + create: + success: + new: + title: + trusted_publisher: + unsupported_type: + github_actions: + repository_owner_help_html: + repository_name_help_html: + workflow_filename_help_html: + environment_help_html: + pending: + rubygem_name_help_html: + duration: + minutes: + other: + one: + hours: + other: + one: + days: + other: + one: + seconds: + other: + one: + form: + optional: + events: + table_component: + event: + time: + additional_info: + redacted: + no_user_agent_info: + rubygem_event: + version: + version_pushed: + version_yanked: + version_unyanked: + version_html: + version_pushed_sha256_html: + version_pushed_by_html: + version_yanked_by_html: + owner: + owner_added: + owner_added_owner_html: + owner_added_authorizer_html: + owner_removed: + owner_removed_owner_html: + owner_removed_by_html: + owner_confirmed: + user_event: + user: + created: + email: + login: + login_success: + webauthn_login: + mfa_method: + mfa_device: + none: + email: + email_verified: + email_sent_subject: + email_sent_from: + email_sent_to: + api_key: + api_key_created: + api_key_deleted: + api_key_name: + api_key_scopes: + api_key_gem_html: + api_key_mfa: + not_required: \ No newline at end of file diff --git a/config/locales/zh-CN.yml b/config/locales/zh-CN.yml index b8034a4a640..c8c9b2c2b23 100644 --- a/config/locales/zh-CN.yml +++ b/config/locales/zh-CN.yml @@ -1,116 +1,225 @@ +--- zh-CN: + credentials_required: 需要凭证 + copied: 已复制! + copy_to_clipboard: 复制到剪贴板 edit: 编辑 - failure_when_forbidden: 请确认 URL 或再次提交 - feed_latest: RubyGems.org | 最新 Gems - feed_subscribed: RubyGems.org | 订阅 Gems - footer_about_html: RubyGems.org 是 Ruby 社区的 Gem 托管服务。
          让你能便捷、快速的发布、管理你的 Gem 以及安装它们。提供 API 查阅可用 Gem 的详细资料。
          参与进来成为一个贡献者,用你的能力推动社区进步。 - footer_sponsors_html: - footer_join_rt_html: + verification_expired: + feed_latest: RubyGems.org | 最新的 Gem + feed_subscribed: RubyGems.org | 订阅的 Gem + footer_about_html: RubyGems.org 是 Ruby 社区的 Gem 托管服务。 立即 发布您的 + Gem安装它们。 使用 API + 来查找更多 可用的 Gem快来成为一名贡献者吧! + 由您自己改善我们的网站。 + footer_sponsors_html: RubyGems.org 是通过与更大的Ruby社区的合作得以实现的。 Fastly + 提供带宽和 CDN 支持, Ruby Central 涵盖基础设施成本,并且 + 资助正在进行的开发和运营工作。 了解更多关于我们的赞助商以及他们是如何合作的。 + footer_join_rt_html: 我们需要您的帮助 —— 通过资助开发人员,来保持 RubyGems.org 的稳定运行,为每个人顺利地服务。 今天就加入 + Ruby Central 吧! form_disable_with: 请稍后... - invalid_page: + invalid_page: 页码超出范围,已重定向到默认页面。 locale_name: 简体中文 none: 无 not_found: 未找到 - api_key_forbidden: - please_sign_up: 拒绝访问。请先在 https://rubygems.org 上注册一个账号。 - please_sign_in: - otp_incorrect: - otp_missing: + forbidden: + api_gem_not_found: + api_key_forbidden: API 密钥没有访问权限 + api_key_soft_deleted: + api_key_insufficient_scope: + please_sign_up: 拒绝访问。请在 https://rubygems.org 上注册一个账号。 + please_sign_in: 请先登录以继续 + otp_incorrect: 您的 OTP 码不正确。请检查后重试。 + otp_missing: 您已启用多因素验证,但是没有提供 OTP 码。请输入后重试。 sign_in: 登录 sign_up: 注册 - dependency_list: - multifactor_authentication: 多重验证 - subtitle: Ruby 社区 Gem 托管 - this_rubygem_could_not_be_found: 未找到这个 RubyGem + dependency_list: 显示所有传递性依赖 + multifactor_authentication: 多因素验证 + subtitle: 您的社区 Gem 托管中心 + this_rubygem_could_not_be_found: 未找到这个 Gem time_ago: "%{duration} 前" title: RubyGems.org update: 更新 - try_again: 请重试 + try_again: 出了点儿问题。请重试。 advanced_search: 高级搜索 + authenticate: 身份认证 + helpers: + submit: + create: + update: activerecord: attributes: linkset: bugs: Bug 追踪 URL - changelog: 更新日志 URL + changelog: 变更记录 URL code: 源代码 URL docs: 文档 URL mail: 邮件列表 URL wiki: Wiki URL - funding: + funding: 募集资金 URL session: password: 密码 - who: Email / 账号 + who: 邮箱 / 用户名 user: avatar: 头像 - email: Email - handle: 账号 + email: 邮箱 + full_name: 全名 + handle: 用户名 password: 密码 + ownership/role: + owner: + admin: + maintainer: + api_key: + oidc_api_key_role: + oidc/id_token: + jti: + api_key_role: + oidc/api_key_role: + api_key_permissions: + oidc/trusted_publisher/github_action: + repository_owner_id: + oidc/pending_trusted_publisher: + rubygem_name: errors: messages: - unpwn: - blocked: + unpwn: 曾出现过数据泄露,不应该再使用 + blocked: 域名 '%{domain}' 因发送垃圾邮件已被禁用。请使用另外有效的个人邮箱。 + models: + api_key: + attributes: + expires_at: + inclusion: + organization: + attributes: + handle: + invalid: + ownership: + attributes: + user_id: + already_confirmed: + already_invited: + ownership_request: + attributes: + user_id: + taken: + existing: + user: + attributes: + handle: + invalid: + version: + attributes: + gem_full_name: + taken: + full_name: + taken: + oidc/rubygem_trusted_publisher: + attributes: + rubygem: + taken: + oidc/pending_trusted_publisher: + attributes: + rubygem_name: + unavailable: + models: + user: 用户 + api_key: + zero: + one: + other: + activemodel: + attributes: + oidc/provider/configuration: + jwks_uri: + id_token_signing_alg_values_supported: + errors: + models: + oidc/api_key_permissions: + attributes: + valid_for: + inclusion: + gems: + too_long: api_keys: + form: + exclusive_scopes: + rubygem_scope: Gem 作用范围 + rubygem_scope_info: 该作用范围仅允许对一个 Gem 进行 推送/撤回 以及 对业主的 添加/移除 + multifactor_auth: 多因素验证——身份认证 + enable_mfa: 启用多因素验证 + expiration: create: - success: + success: 新的 API 密钥已创建 + invalid_gem: 该 Gem 未被此 API 密钥所允许的作用范围包含 destroy: - success: + success: 成功删除 API 密钥:%{name} index: - api_keys: - name: - scopes: - age: - last_access: - action: - delete: - confirm: - confirm_all: - new_key: - index_rubygems: - push_rubygem: - yank_rubygem: - add_owner: - remove_owner: - access_webhooks: - show_dashboard: - reset: - save_key: + api_keys: API 密钥 + name: 名称 + scopes: 作用范围 + gem: Gem + age: 有效期 + last_access: 上一次访问 + action: 行为 + delete: 删除 + confirm: 该 API 密钥 将作废。您确定吗? + confirm_all: 所有 API 密钥 均将作废。您确定吗? + new_key: 新的 API 密钥 + index_rubygems: 为 Gem 编入索引 + push_rubygem: 推送 Gem + yank_rubygem: 撤回 Gem + add_owner: 添加 Gem 业主 + remove_owner: 移除(某个)Gem 业主 + update_owner: + access_webhooks: 访问后 (Access) Webhook + show_dashboard: 显示仪表盘 + configure_trusted_publishers: + reset: 重置 + save_key: 请注意在此之后我们不会再次向您显示该密钥。新的 API 密钥为: + mfa: 多因素验证 + expiration: new: - new_api_key: + new_api_key: 生成新 API 密钥 reset: - success: + success: 所有的 API 密钥已删除 update: - success: + success: API 密钥更新成功 + invalid_gem: 该 Gem 未被此 API 密钥所允许的作用范围包含 edit: - edit_api_key: - clearance_mailer: - change_password: - title: - subtitle: + edit_api_key: 编辑 API 密钥 + invalid_key: 已作废的 API 密钥不能被编辑。请删除它并创建一个新的 API 密钥。 + all_gems: 所有 Gem + gem_ownership_removed: 对 %{rubygem_name} 的所有权已经被移除(在使用此 API 密钥决定作用范围后) dashboards: show: creating_link_text: 创建 gem_link_text: Gem 页面 - latest: 最近更新 - latest_title: 最近更新 RSS 订阅 + latest: 最新的更新 + latest_title: 最新的 RSS 订阅更新 migrating_link_text: 迁移 - mine: 我的 Gems - my_subscriptions: 我的订阅 - no_owned_html: 你没有发布过任何一个 Gem。可以尝试阅读 %{creating_link} 教程,或看看 %{migrating_link} 教程。 - no_subscriptions_html: 你目前没有订阅任何 Gem,访问 %{gem_link} 可以订阅一个! - title: 控制台 + mine: 我的 Gem + my_subscriptions: 订阅 + no_owned_html: 您还没有发布过任何 Gem。可以看看 %{creating_link} 指南,或从 RubyForge %{migrating_link} + 一个 Gem。 + no_subscriptions_html: 您没有订阅任何 Gem,访问 %{gem_link} 订阅一个! + title: 仪表盘 + dependencies: + show: + click_to_expand: 单击箭头图标展开。 email_confirmations: create: - promise_resend: + promise_resend: 如果您的帐户存在,我们将通过邮件发送确认链接来激活您的帐户。 new: - submit: 重新发送 + submit: 确认重新发送 title: 重新发送确认邮件 - will_email_notice: 我们将会通过 Email 发送确认激活账号的链接给你。 + will_email_notice: 我们将会通过 Email 发送确认激活账号的链接给您。 update: confirmed_email: 邮箱地址验证成功。 + token_failure: 请再次确认 URL 或尝试重新提交 home: index: downloads_counting_html: 总下载次数 - find_blurb: 寻找、安装以及发布 RubyGems + find_blurb: 查找、安装以及发布 Gem learn: install_rubygems: 安装 RubyGems layouts: @@ -124,7 +233,7 @@ zh-CN: designed_by: 设计 discussion_forum: 讨论 gems_served_by: 服务 - guides: 教程 + guides: 指南 help: 帮助 hosted_by: 托管 monitored_by: 监控 @@ -137,345 +246,747 @@ zh-CN: supported_by: 支持 tested_by: 测试 tracking_by: 追踪 - uptime: 服务状况 + uptime: 服务运行时间 + verified_by: 验证 + secured_by: 安全保护 + looking_for_maintainers: 招募维护者 header: - dashboard: 控制台 - edit_profile: - settings: - search_gem_html: 搜索 Gems… + dashboard: 仪表盘 + settings: 设置 + edit_profile: 编辑个人信息 + search_gem_html: 搜索 Gem …… sign_in: 登录 sign_out: 退出 sign_up: 注册 + mfa_banner_html: mailer: - confirm_your_email: - confirmation_subject: - link_expiration_explanation_html: + confirm_your_email: 请在发送到您的邮件中点击链接,确认您的邮箱地址。 + confirmation_subject: 请确认您的邮箱地址 + link_expiration_explanation_html: 请记住,此链接仅在 3 小时内有效。您可以用重新发送确认邮件页面来请求获得一个新链接。 email_confirmation: - title: - subtitle: - confirmation_link: - welcome_message: + title: 邮箱确认 + subtitle: 即将完成! + confirmation_link: 确认邮箱地址 + welcome_message: 欢迎来到 RubyGems.org!请点击下面的链接来验证您的邮箱。 email_reset: - title: - subtitle: - visit_link_instructions: + title: 邮箱重置 + subtitle: 你好啊,%{handle}! + visit_link_instructions: 您在 RubyGems.org 上更改了您的邮箱地址,请点击以下链接重新激活您的帐户。 deletion_complete: - title: - subtitle: - subject: - body_html: + title: 删除完成 + subtitle: 拜拜! + subject: 您的账户已经从 RubyGems.org 中删除 + body_html: 您从 RubyGems.org 中删除账户的请求已经被处理。您随时可以使用我们的 %{sign_up} 页面注册新的账号。 deletion_failed: - title: - subtitle: - subject: - body_html: + title: 删除失败 + subtitle: 抱歉! + subject: 您在 RubyGems.org 上关于删除账户的请求失败了 + body_html: 您已请求在 RubyGems.org 中删除账户。很遗憾,我们无法处理您的请求。请稍后重试。如果问题依然存在,请 %{contact} + 我们。 notifiers_changed: - subject: - title: - subtitle: - 'on': - off_html: + subject: 您改变了您在 RubyGems.org 上的邮件通知设置 + title: 邮件通知 + subtitle: 你好啊,%{handle}! + 'on': 开启 + off_html: "关闭%{authorizer} 添加为 Gem %{gem} by的业主之一。请点击下方的链接来确认您的所有权。 + link_expiration_explanation_html: 请注意,此链接仅在 %{expiry_hours} 内有效。您可以在登录后从 Gem + %{gem} 页面重新发送确认邮件。 owner_added: - subject_self: - subject_others: - title: - subtitle: - body_self_html: - body_others_html: + subject_self: 您已被添加为 Gem %{gem} 的业主之一 + subject_others: 用户 %{owner_handle} 已被添加为 Gem %{gem} 的业主之一 + title: 新增业主 + subtitle: 你好啊,%{user_handle}! + body_self_html: 被添加为 RubyGems.org 上 Gem %{gem} + 的业主之一。 + body_others_html: %{owner_handle}%{authorizer} 添加为 Gem %{gem} 的业主之一。您收到此通知是因为您是 + %{gem} 的业主之一。 owner_removed: + subject: 您对 Gem %{gem} 的业主身份已被移除 + title: 业主移除 + subtitle: 你好啊,%{user_handle}! + body_html: 在 RubyGems.org 上对 Gem %{gem} + 的业主身份已被 %{remover} 移除 + owner_updated: subject: title: subtitle: body_html: + body_text: + ownerhip_request_closed: + title: 所有权申请 + subtitle: 你好啊,%{hand}! + body_html: 感谢您申请对 %{gem} 的所有权。我们很遗憾地通知您,您的所有权申请已被该 Gem 的业主驳回。 + ownerhip_request_approved: + body_html: 恭喜您!您对 %{gem} 的所有权申请已被批准。您已被添加为该 Gem 的业主之一。 + new_ownership_requests: + body_html: + zero: + one: + other: "%{gem}%{count} 个新的所有权申请。请点击下面的按钮查看所有请求。" + button: 所有权申请 + disable_notifications: 要停止接收这些信息,请更新您的 + owners_page: 所有权 + web_hook_deleted: + title: 删除 webhook + subject: 您的 RubyGems.org webhook 已被删除 + subtitle: 你好啊,%{handle}! + body_text: 您 POST 到 ${url} 的 webhook 在经过 %{failures} 次失败后已被删除。 + body_html: 您 POST%{url} 的 webhook + 在经过 %{failures} 次失败后已被删除。 + global_text: 这个 webhook 曾在任何 Gem 被推送时被调用。 + global_html: 这个 webhook 曾在任何 Gem 被推送时被调用。 + gem_text: 这个 webhook 在 %{gem} 被推送时被调用。 + gem_html: 这个 webhook 在 %{gem} + 被推送时被调用。 + web_hook_disabled: + title: 停用 webhook + subject: 您的 RubyGems.org webhook 已被停用 + subtitle: 你好啊,%{handle}! + body_text: | + 由于 %{disabled_reason} 的原因,POST 到 %{url} 的 webhook 已被停用。 + 它最后一次成功是在 %{last_success},从那以后失败了 %{failures_since_last_success} 次。 + 您可以通过运行 `% %{delete_command}` 删除此 webhook。 + body_html: | +

          由于 %{disabled_reason} 的原因,POST 到 %{url} 的 webhook 已被停用。

          +

          它最后一次成功是在 %{last_success},从那以后失败了 %{failures_since_last_success} 次。

          +

          您可以通过运行 `% %{delete_command}` 删除此 webhook。

          + global_text: 这个 webhook 曾在任何 Gem 被推送时被调用。 + global_html: 这个 webhook 曾在任何 Gem 被推送时被调用。 + gem_text: 这个 webhook 在 %{gem} 被推送时被调用。 + gem_html: 这个 webhook 在 %{gem} + 被推送时被调用。 + gem_trusted_publisher_added: + title: + admin_manual: + title: news: show: - title: 全部新发布 Gems - all_gems: 全部 - popular_gems: 热门 + title: 新的发布 — 所有 Gem + all_gems: 所有 Gem + popular_gems: 热门 Gem popular: - title: 热门新发布 Gems + title: 新的发布 — 热门 Gem pages: about: - founding_html: 此项目创立由 %{founder} 于2009年4月,发展过程中有超过 %{contributors} 贡献者以及 %{downloads}。自从 RubyGems 1.3.6 发布以后,本站名称由 Gemcutter 改名为 %{title},从而巩固在 Ruby 社区网站中和核心作用。 - support_html: 虽然 Gemcutter 不是由一个具体的公司来运作,但也有很多的帮助让我们走出了这么远。目前的设计、插画、以及网站的前端开发都是由 %{dockyard} 提供支持。%{github} 帮助我们轻松的协助和共享代码。本站最初部署在 %{heroku},其一流的服务,有助于证明 Gemcutter 是可以依赖、可行的解决方案。Our infrastructure is currently hosted on %{aws}. - technical_html: 关于本站的一些技术方案:100% Ruby。主站基于 %{rails}。Gems 文件放在 %{s3}, served by %{fastly}, 上面,它能缩短在 Gem 提交到可以下载之间的耗时。更多的信息可以在 GitHub 上阅读 %{source_code},基于 %{license} 协议。 + contributors_amount: + downloads_amount: + checkout_code: + mit_licensed: + logo_header: + logo_details: + founding_html: 此项目由 %{founder} 创立于 2009 年 4 月,之后有超过 %{contributors} 贡献者以及 %{downloads}。自从 + RubyGems 1.3.6 发布以后,本站名称由 Gemcutter 改名为 %{title},从而巩固在 Ruby 社区网站中的核心作用。 + support_html: 虽然 RubyGems.org 不是由一个具体的公司来运作,但也有很多帮助让我们走了这么远。网站目前的设计、插画、和前端开发都是由 + %{dockyard} 创建。%{github} 也帮助我们轻松地协作和共享代码。本站最初部署于 %{heroku},其一流的服务助证了 RubyGems + 是整个社区可以依赖的可行方案。当前,我们的基础设施托管于 %{aws}. + technical_html: 关于本站的一些技术方案:100% Ruby。主站是一个 %{rails} 应用。Gem 托管于 %{s3}, 由 %{fastly} + 提供服务,从发布一个新 Gem 到做好安装准备之间的耗时通常只有几秒。更多信息可以在 GitHub 上阅读基于 %{license} 协议的 %{source_code}。 title: 关于 purpose: - better_api: 提供更好的 API 来为 Gems 服务 + better_api: 提供更好的 API 来为 Gem 服务 enable_community: 可以让社区来改善本站 header: 欢迎来到 %{site}, 这是 Ruby 社区的 Gem 托管服务。此项目有三个目的: transparent_pages: 构建更透明以及便于浏览的项目页面 data: - title: + title: RubyGems.org 数据转储 download: - title: + title: 下载 RubyGems faq: - title: + title: 常问问题 migrate: - title: + title: 迁移 Gem security: - title: + title: 安全 sponsors: - title: + title: 赞助商 + password_mailer: + change_password: + closing: + opening: + title: 修改密码 + subtitle: 你好啊,%{handle}! passwords: edit: - submit: 更新密码 + submit: 保存该密码 title: 重置密码 + token_failure: 请再次确认 URL 或尝试重新提交 new: submit: 重置密码 - title: 修改密码 - will_email_notice: 我们将会通过 Email 发送修改密码的链接给你。 - otp_prompt: - authenticate: - multifactor_auths: - incorrect_otp: - otp_code: - require_mfa_disabled: - require_mfa_enabled: - new: - title: - scan_prompt: - otp_prompt: - confirm: - enable: - account: - key: - time_based: + title: 修改您的密码 + will_email_notice: 我们将会向您发送一封包含修改密码链接的邮件。 create: - qrcode_expired: success: + failure_on_missing_email: + update: + failure: + multifactor_auths: + session_expired: 您的登录页面会话已过期。 + require_mfa_enabled: 您的多因素验证已停用,请先启用。 + require_webauthn_enabled: 您还没有启用任何安全设备。您必须首先关联一个设备到您的账户。 + setup_required_html: 为了保护您的帐户和您的 Gem,您需要设置多因素验证。请阅读我们的博客文章了解详情。 + setup_recommended: 为了保护您的帐户和您的 Gem,我们鼓励您设置多因素验证。在将来,您的帐户将被要求启用多因素验证。 + strong_mfa_level_required_html: 为了保护您的帐户和您的 Gem,您需要将您的多因素验证级别更改为 "UI and gem signin" + 或 "UI and API"。请阅读我们的博客文章了解详情。 + strong_mfa_level_recommended: 为了保护您的帐户和您的 Gem,我们建议您将您的多因素验证级别更改为 "UI and gem signin" + 或 "UI and API"。在将来,您的帐户将被要求在其中某一个级别上启用多因素验证。 + setup_webauthn_html: + api: + mfa_required: + mfa_required_not_yet_enabled: + mfa_required_weak_level_enabled: + mfa_recommended_not_yet_enabled: + mfa_recommended_weak_level_enabled: recovery: - continue: - title: - note: - destroy: - success: + continue: 继续 + title: 恢复码 + saved: 我声明我已经保存了我的恢复码。 + confirm_dialog: + note_html: 请 复制并保存 这些恢复码。如果您丢失了身份验证设备,您可以使用这些恢复码登录并重置您的多因素验证配置。每个恢复码只能使用一次。 + already_generated: 您应该已经保存了您的恢复码。 update: - success: + invalid_level: 无效的 MFA 级别。 + success: 您已成功修改多因素验证级别。 + prompt: + webauthn_credential_note: 使用如 Touch ID, YubiKey 等安全设备来认证身份。 + sign_in_with_webauthn_credential: 通过安全设备认证人身 + otp_code: 一次性密码 + otp_or_recovery: OTP 或 恢复码 + recovery_code: 恢复码 + recovery_code_html: 如果您无法访问用于多因素验证的设备或安全设备,您可以使用一个有效的 恢复码。 + security_device: 安全设备 + verify_code: 验证码 + totps: + incorrect_otp: 你的 OTP 码 不正确。 + require_totp_disabled: 您的多因素验证已启用。要重新配置多因素验证,您必须首先停用它。 + require_totp_enabled: 您还没有启用一个身份验证器应用。您必须先启用它。 + new: + title: 启用多因素验证 + scan_prompt: 请用您的身份验证程序扫描二维码。如果您没办法扫描,请在您的程序中手动输入下面的内容。 + otp_prompt: 输入身份验证程序上的数字代码以继续。 + confirm: 我已把复原码存放在安全的地方。 + enable: 启用 + account: 账户:%{account} + key: 密钥:%{key} + time_based: 基于时间的:是 + create: + qrcode_expired: 二维码和密钥已过期。请尝试重新注册一个新的设备。 + success: 您已成功启用多因素验证。 + destroy: + success: 您已成功停用多因素验证。 notifiers: update: - success: + success: 您已成功更新邮件通知设置。 show: - info: - 'on': - 'off': - recommended: + info: 为了帮助检测未经授权的 Gem 或所有权变更,当您拥有的 Gem 的每一个新版本被推送、撤回时,或添加了新业主时,我们每次都会向您发送一封邮件。通过接收和阅读这些电子邮件,您帮助大家一起保护了 + Ruby 的生态系统。 + 'on': 开启 + 'off': 关闭 + recommended: 推荐 + title: 邮件通知 + update: 更新 + owner_heading: 所有权通知 + owner_request_heading: 所有权申请通知 + push_heading: 推送通知 + webauthn_verifications: + expired_or_already_used: 您链接中使用的的令牌已过期或已被使用。 + no_port: 没有提供端口。请再试一次。 + pending: + prompt: + title: 使用安全设备进行身份认证 + authenticating_as: 身份认证为 + authenticate: 身份认证 + no_webauthn_devices: 您没有启用任何安全设备。 + successful_verification: + title: 成功! + close_browser: 请关闭浏览器。 + failed_verification: + title: 错误 — 验证失败 + close_browser: 请关闭该浏览器并重试。 + owners: + confirm: + confirmed_email: 您已被添加为 Gem %{gem} 的业主之一 + token_expired: 确认令牌已过期。请尝试从该 Gem 页面重新发送令牌。 + index: + add_owner: 添加业主 + name: 业主 + mfa: 多因素验证状态 + status: 状态 + confirmed_at: 批准于 + added_by: 添加 + action: 行为 + email_field: Email / Handle + submit_button: 添加业主 + info: 添加或移除业主 + confirmed: 已批准 + pending: 待定 + confirm_remove: 您确定您想要从业主中移除该用户吗? + role: + role_field: + resend_confirmation: + resent_notice: 一封确认邮件已被重新发送到您的邮箱中 + create: + success_notice: "%{handle} 已添加为还未经批准的业主。在用户点击发送到其邮箱的批准邮件后,所有权访问才将被启用。" + destroy: + removed_notice: "%{owner_name} 已成功从业主中移除" + failed_notice: 不能删除该 Gem 的唯一业主 + edit: + role: title: - update: - push_heading: - owner_heading: + update: + success_notice: + update_current_user_role: + mfa_required: 该 Gem 已启用多因素验证,请在您的帐户上设置多因素验证。 settings: edit: - title: + title: 编辑设置 + webauthn_credentials: 安全设备 + no_webauthn_credentials: 您还没有任何安全设备 + webauthn_credential_note: 安全设备可以是任何符合 FIDO2 标准的设备,比如安全密钥、生物密钥等。 + otp_code: OTP 码或恢复码 api_access: - confirm_reset: 确定要重置吗?此动作执行后将无法撤销 - credentials_html: 如果你希望在命令行中使用 %{gem_commands_link},你需要一个 %{gem_credentials_file} 文件,可用下面的命令生成: - key_is_html: 你的 API Key:%{key}。 + confirm_reset: 您确定吗?该命令执行后将无法撤销 + credentials_html: 如果您希望在命令行中使用 %{gem_commands_link},您需要一个 %{gem_credentials_file} + 文件,该文件可通过下面的命令生成: + key_is_html: 您的 API 密钥为:%{key}。 link_text: Gem 命令 - reset: 重置 API Key + reset: 重置我的 API 密钥 + reset_all: 删除所有作用范围的 API 密钥 title: API 访问 reset_password: title: 重置密码 mfa: - multifactor_auth: - disabled: - go_settings: + multifactor_auth: 多因素验证 + otp: 身份认证应用 + disabled_html: 您尚未启用基于 OTP 的多因素身份验证。请参考RubyGems + 多因素验证指南 来了解更多关于多因素验证级别的信息。 + go_settings: 注册新设备 + level_html: 您已启用多因素身份验证。请点击 “更新” 来改变您的 MFA 级别。请参考 RubyGems + 多重身份验证指南 来了解更多 MFA 级别的信息。 + enabled_note: 您已启用多因素身份验证。请输入您从身份验证器上获得的 OTP 码,或使用您已生效的恢复码来禁用。 + update: 更新 + disable: enabled: - update: + disabled: level: - title: - disabled: - ui_only: - ui_and_api: - ui_and_gem_signin: + title: 多因素验证级别 + disabled: 停用 + ui_only: 仅 UI + ui_and_api: UI 和 API(推荐使用) + ui_and_gem_signin: UI 和 Gem 签署 profiles: + adoptions: + no_ownership_calls: 您没有为您的任何 Gem 创建任何所有权的调用。 + no_ownership_requests: 您没有创建任何所有权申请。 + title: 领养 + subtitle_html: 寻求新的维护者或申请所有权 (了解更多) edit: - change_avatar: - email_awaiting_confirmation: 请验证你的新邮箱地址 %{unconfirmed_email} - enter_password: 输入密码 - hide_email: 在公开的个人资料里面隐藏我的 Email - optional_twitter_username: Twitter 账号(可选) + change_avatar: 修改头像 + disabled_avatar_html: + email_awaiting_confirmation: 请确认您新的邮箱地址 %{unconfirmed_email} + enter_password: 请输入您账户的密码 + optional_full_name: 可填。将公开显示 + optional_twitter_username: 可选 X 用户名。这将会被公开显示 + twitter_username: 用户名 title: 修改个人资料 delete: delete: 删除 delete_profile: 删除个人资料 - warning: 警告 + warning: 删除个人资料是不可逆的操作。即使与支持人员联系也无法撤销。请确定无误! delete: - title: - confirm: - instructions: - list_only_owner_html: - list_multi_owner: - warning: 警告 + title: 删除个人资料 + confirm: 确认 + instructions: 我们很遗憾您要离开。请在下面的对话框中输入您的密码并确认。 + list_only_owner_html: 当您删除您的个人资料时,这些 Gem 也将被撤回。如果您想在删除个人资料之前添加业主,您可以使用 %{command_link} + 命令。 + list_multi_owner: 您将失去访问这些 Gem 的权限,但这些 Gem 的其他业主将仍然可以访问。 + warning: 删除个人资料是不可逆的操作。即使与支持人员联系也无法撤销。请确定无误! rubygem: - owners_header: + owners_header: 业主 destroy: - request_queued: + request_queued: 您的帐户删除请求已被置于处理队列中。当您的申请被处理后,我们会给您发一封确认邮件。 update: - confirmation_mail_sent: - updated: - request_denied: + confirmation_mail_sent: 几分钟后您会收到一封邮件,它包含了确认您新邮箱地址的操作说明。 + updated: 您的个人资料已更新 + public_email: 在公开的个人资料里面展示我的 Email + request_denied: 请求被驳回,您的密码无法被我们验证通过。 + show: + title: "%{username} 的个人资料" + security_events: + title: + description_html: rubygems: aside: - bundler_header: Gemfile - copied: 已复制 - copy_to_clipboard: 复制到剪贴板 downloads_for_this_version: 这个版本 - install: 安装 + gem_version_age: 版本发布 required_ruby_version: 需要的 Ruby 版本 required_rubygems_version: 需要的 RubyGems 版本 + requires_mfa: 新的版本需要开启多因素验证 + released_with_mfa: 使用多因素验证发布的版本 links: badge: 徽章 bugs: Bug 追踪 - changelog: 更新日志 + changelog: 变更记录 code: 源代码 docs: 文档 download: 下载 - header: 相关链接 + funding: 募集资金 + header: 链接 home: 主页 mail: 邮件列表 report_abuse: 举报投诉 reverse_dependencies: 反向依赖 - review_changes: + review_changes: 审查变更 rss: RSS subscribe: 订阅 unsubscribe: 取消订阅 wiki: Wiki - resend_ownership_confirmation: - ownership: - blacklisted: - blacklisted_namespace: + resend_ownership_confirmation: 重新发送 + ownership: 所有权 + oidc: + api_key_role: + name: + new: + trusted_publishers: + reserved: + reserved_namespace: 该命名空间由 RubyGems.org 保留。 dependencies: - header: "%{title} 依赖关系" + header: "%{title} 依赖" gem_members: authors_header: 作者 - not_using_mfa_warning_show: - not_using_mfa_warning_hide: - owners_header: 所有者 - pushed_by: - using_mfa_info: - yanked_by: - sha_256_checksum: SHA 256 checksum + self_no_mfa_warning_html: 请考虑 启用多因素身份验证(MFA) 来保障您的帐户安全。 + not_using_mfa_warning_show: "* 一些业主当前还没有使用多因素验证(MFA)。请点击查看完整列表。" + not_using_mfa_warning_hide: "* 以下业主还未使用多因素验证(MFA)。点击隐藏。" + owners_header: 业主 + pushed_by: 推送 + using_mfa_info: "* 所有业主都已使用多因素验证(MFA)。" + yanked_by: 撤回 + sha_256_checksum: SHA 256 校验和 + signature_period: 签名有效期 + expired: 过期 + version_navigation: + previous_version: "← 以前的版本" + next_version: 接下来的版本 → index: downloads: 下载 - title: Gems + title: Gem show: + bundler_header: Gemfile + install: 安装 licenses_header: one: 许可 other: 许可 no_licenses: 无 requirements_header: 要求 - show_all_versions: 显示所有 (%{count} 个版本) + show_all_versions: 显示所有版本 (共 %{count} 个) versions_header: 版本列表 - yanked_notice: 这个 Gem 版本已经 yanked(废弃了),他无法提供下载,也无法被其他的 Gem 依赖。 + yanked_notice: 这个 Gem 版本已经撤回了,无法直接下载,也无法被其他 Gem 依赖。 show_yanked: - not_hosted_notice: 这个 Gem 目前没有托管在 Gemcutter。 + not_hosted_notice: 这个 Gem 目前没有被托管在 RubyGems.org 中。这个 Gem 撤回的版本可能已经存在了。 reserved_namespace_html: - one: - other: + one: 这个 Gem 以前存在过,但被它的业主移除了。RubyGems.org 团队将会把这个 Gem 的名称多保留 1 天。在此之后,任何人都可以使用 + gem push 申请此 Gem 名称。
          如果您是该 Gem 的前业主,您可以使用 gem owner + 命令更改该 Gem 的业主。您也可以使用 gem push 创建这个 gem 的新版本。 + other: 这个 Gem 以前存在过,但被它的业主移除了。RubyGems.org 团队将会把这个 Gem 的名称多保留 %{count} 天。在此之后,任何人都可以使用 + gem push 申请此 Gem 名称。
          如果您是该 Gem 的前业主,您可以使用 gem owner + 命令更改该 Gem 的业主。您也可以使用 gem push 创建这个 gem 的新版本。 + security_events: + title: + description_html: reverse_dependencies: index: title: "%{name} 的反向依赖" - subtitle: + subtitle: 以下 Gem 的最新版本需要 %{name} + no_reverse_dependencies: 这个 Gem 没有反向依赖。 search: - search_reverse_dependencies_html: + search_reverse_dependencies_html: 搜索反向依赖 Gem… searches: advanced: name: 名称 - summary: 摘要 + summary: 概要 description: 描述 downloads: 下载数 - updated: 更新时间 + updated: 更新 + yanked: 撤回 show: - subtitle: "%{query}" - month_update: - week_update: - filter: - yanked: - suggestion: + subtitle_html: "%{query}" + month_update: 上个月更新 (%{count}) + week_update: 上周更新 (%{count}) + filter: 过滤: + yanked: 撤回 (%{count}) + suggestion: 您是想 sessions: new: - forgot_password: 忘记了密码? + forgot_password: 忘了密码? resend_confirmation: 没有收到确认邮件? verify: - title: - confirm: - notice: + title: 确认密码 + confirm: 确认 + notice: 请确认密码后继续 + create: + account_blocked: 您的账户已被 RubyGems 团队拉黑。请发送邮件至 support@rubygems.org 以恢复您的帐户。 stats: index: - title: - all_time_most_downloaded: 历史下载次数排行 - total_downloads: 下载总次数 - total_gems: Gems 总数 + title: 统计数据 + all_time_most_downloaded: 至今最多下载 + total_downloads: 下载总量 + total_gems: Gem 总数 total_users: 用户总数 users: create: - email_sent: 确认邮件已发送到您的电子邮件地址。 + email_sent: 确认邮件已发送至您的邮箱。 new: - have_account: 已经有账号了? + have_account: 已经有一个账号了? versions: index: - not_hosted_notice: 此 Gem 目前没有托管在 Gemcutter。 + not_hosted_notice: 此 Gem 目前没有托管在 RubyGems.org 中。 title: "%{name} 的所有版本" - versions_since: 自 %{since} 以来有 %{count} 个版本 + versions_since: + other: 自 %{since} 以来有 %{count} 个版本 + one: 自 %{since} 以来有 %{count} 个版本 + imported_gem_version_notice: 此版本的 Gem 已在 %{import_date} 导入到 RubyGems.org 中。显示的日期由作者在 + gemspec 中指定。 version: - yanked: 已废弃 - will_paginate: - next_label: 下一页 - page_gap: "…" - previous_label: 上一页 - page_entries_info: - multi_page: 显示所有 %{count} %{model}中的%{from}-%{to} 条 - multi_page_html: 显示所有 %{count} %{model}中的%{from} - %{to} 条 - single_page: - one: 显示 1 %{model} - other: 显示所有 %{count} %{model} - zero: 暂时没有 %{model} - single_page_html: - one: 显示 1 %{model} - other: 显示 所有 %{count} %{model} - zero: 暂时没有 %{model} - owners: - confirm: - confirmed_email: - token_expired: + yanked: 已撤回 + adoptions: index: - add_owner: - name: - mfa: - status: - confirmed_at: - added_by: - action: - info: - email_field: - submit_button: - confirmed: - pending: - confirm_remove: - resend_confirmation: - resent_notice: + title: 领养 + subtitle_owner_html: 请新的维护者加入 %{gem} (了解更多) + subtitle_user_html: 申请对 %{gem} 的所有权 (了解更多) + ownership_calls: 所有权调用 + no_ownership_calls: 没有对 %{gem} 的所有权调用。该 Gem 的业主并没有在寻求新的维护者。 + ownership_calls: + update: + success_notice: 对 %{gem} 的所有权调用已关闭。 create: - success_notice: - destroy: - removed_notice: - failed_notice: + success_notice: 已创建对 %{gem} 的所有权调用。 + index: + title: 维护者招募 + subtitle_html: RubyGems 正在寻找新的维护者加入 (了解更多) + share_requirements: 请分享您在哪些方面需要帮助 + note_for_applicants: 申请人须知: + created_by: 创建 + details: 详情 + apply: 申请 + close: 关闭 + markup_supported_html: 支持 Rdoc + 标记语法 + create_call: 创建所有权调用 + ownership_requests: + create: + success_notice: 您的所有权申请已经被提交 + update: + approved_notice: 所有权申请已被批准。%{name} 已被添加为新的业主之一。 + closed_notice: 所有权申请已经被关闭。 + close: + success_notice: 对 %{gem} 的所有开放所有权申请都已被关闭。 + ownership_requests: 所有权申请 + note_for_owners: 业主须知: + your_ownership_requests: 您的所有权申请 + close_all: 关闭所有 + approve: 批准 + gems_published: 已发布的 Gem + created_at: 创建于 + no_ownership_requests: 加入您项目的申请将显示在这里。还没有对 %{gem} 的所有权申请。 + create_req: 创建所有权申请 + signin_to_create_html: 请 登录 来新建一个所有权申请。 + webauthn_credentials: + callback: + success: 您已成功注册一个安全设备。 + recovery: + continue: 继续 + title: 您已成功添加一个安全设备 + notice_html: 请 复制并粘贴这些恢复码。如果您丢失了安全设备,您可以使用这些恢复码登录。每个恢复码码只能使用一次。 + saved: 我确认我已经保存了我的恢复码。 + webauthn_credential: + confirm_delete: 凭证已删除 + delete_failed: 不能删除凭证 + delete: 删除 + confirm: 您确定您想要删除该凭证吗? + saved: 安全设备已成功创建 + form: + new_device: 创建一个新的安全设备 + nickname: 昵称 + submit: 注册设备 + oidc: + api_key_roles: + index: + api_key_roles: + new_role: + show: + api_key_role_name: + automate_gh_actions_publishing: + view_provider: + edit_role: + delete_role: + confirm_delete: + deleted_at_html: + edit: + edit_role: + git_hub_actions_workflow: + title: + configured_for_html: + to_automate_html: + not_github: + not_push: + a_gem: + instructions_html: + new: + title: + update: + success: + create: + success: + destroy: + success: + form: + add_condition: + remove_condition: + add_statement: + remove_statement: + deleted: + providers: + index: + title: + description_html: + show: + title: + id_tokens: + index: + title: + show: + title: + rubygem_trusted_publishers: + index: + title: + subtitle_owner_html: + delete: + create: + description_html: + destroy: + success: + create: + success: + new: + title: + subtitle_owner_html: + pending_trusted_publishers: + index: + title: + valid_for_html: + delete: + create: + description_html: + destroy: + success: + create: + success: + new: + title: + trusted_publisher: + unsupported_type: + github_actions: + repository_owner_help_html: + repository_name_help_html: + workflow_filename_help_html: + environment_help_html: + pending: + rubygem_name_help_html: + duration: + minutes: + other: + one: + hours: + other: + one: + days: + other: + one: + seconds: + other: + one: + form: + optional: + events: + table_component: + event: + time: + additional_info: + redacted: + no_user_agent_info: + rubygem_event: + version: + version_pushed: + version_yanked: + version_unyanked: + version_html: + version_pushed_sha256_html: + version_pushed_by_html: + version_yanked_by_html: + owner: + owner_added: + owner_added_owner_html: + owner_added_authorizer_html: + owner_removed: + owner_removed_owner_html: + owner_removed_by_html: + owner_confirmed: + user_event: + user: + created: + email: + login: + login_success: + webauthn_login: + mfa_method: + mfa_device: + none: + email: + email_verified: + email_sent_subject: + email_sent_from: + email_sent_to: + api_key: + api_key_created: + api_key_deleted: + api_key_name: + api_key_scopes: + api_key_gem_html: + api_key_mfa: + not_required: \ No newline at end of file diff --git a/config/locales/zh-TW.yml b/config/locales/zh-TW.yml index ed420db46d9..38c6ad49a77 100644 --- a/config/locales/zh-TW.yml +++ b/config/locales/zh-TW.yml @@ -1,32 +1,50 @@ +--- zh-TW: + credentials_required: 需要認證 + copied: 已複製 + copy_to_clipboard: 複製 edit: 編輯 - failure_when_forbidden: 請確認 URL 或再次提交 + verification_expired: feed_latest: RubyGems.org | 最新 Gems feed_subscribed: RubyGems.org | 訂閱 Gems - footer_about_html: RubyGems.org 是 Ruby 社群的 Gem 套件管理服務,讓你能立即地發佈及安裝你的 Gem 套件,並且利用 API 查詢及操作可用 Gem 的詳細資訊。
          現在就成為貢獻者,貢獻一己之力來改善本站。 - footer_sponsors_html: - footer_join_rt_html: + footer_about_html: RubyGems.org 是 Ruby 社群的 Gem 套件管理服務,讓您能立即地發佈安裝您的 Gem 套件,並且利用 API 查詢及操作可用 Gem 的詳細資訊。
          現在就成為貢獻者,貢獻一己之力來改善本站。 + footer_sponsors_html: RubyGems.org 透過與廣大的 Ruby 社群合作而誕生。 Fastly + 提供頻寬和 CDN 支援, Ruby Central 支付設備費用,並為進行中的開發和營運工作提供資金。 + 進一步了解我們的贊助商和它們是如何合作的。 + footer_join_rt_html: 我們需要您的幫助來資助開發者為保證 RubyGems.org 能夠順利運行所耗費的時間。 立刻加入 + Ruby Central。 form_disable_with: 請稍候... - invalid_page: + invalid_page: 頁碼超出範圍。已重新導向至預設頁面。 locale_name: 正體中文 none: 無 not_found: 沒有找到 - api_key_forbidden: - please_sign_up: 無法存取,請先在 https://rubygems.org 上註冊帳號 - please_sign_in: - otp_incorrect: - otp_missing: + forbidden: + api_gem_not_found: + api_key_forbidden: API 金鑰沒有存取權限 + api_key_soft_deleted: + api_key_insufficient_scope: + please_sign_up: 存取遭拒。請先在 https://rubygems.org 上註冊帳號 + please_sign_in: 請登入以繼續。 + otp_incorrect: 您的 OTP 碼不正確。請檢查後再試一次。 + otp_missing: 您已啟用多重要素驗證,但是沒有輸入 OTP 碼。請輸入後再試一次。 sign_in: 登入 sign_up: 註冊 dependency_list: - multifactor_authentication: MFA 驗證 + multifactor_authentication: 多重要素驗證 subtitle: Ruby 社群 Gem 套件管理平台 - this_rubygem_could_not_be_found: 找不到這個 gem + this_rubygem_could_not_be_found: 找不到此 rubygem。 time_ago: "%{duration} 前" title: RubyGems.org update: 更新 - try_again: 請再試一次 + try_again: 發生錯誤。請再試一次。 advanced_search: 進階搜尋 + authenticate: 驗證 + helpers: + submit: + create: + update: activerecord: attributes: linkset: @@ -39,74 +57,164 @@ zh-TW: funding: session: password: 密碼 - who: Email / 帳號 + who: 電子郵件地址或帳號 user: avatar: 頭像 - email: Email + email: 電子郵件地址 + full_name: 全名 handle: 帳號 password: 密碼 + ownership/role: + owner: + admin: + maintainer: + api_key: + oidc_api_key_role: OIDC API 金鑰角色 + oidc/id_token: + jti: JWT ID + api_key_role: API 金鑰角色 + oidc/api_key_role: + api_key_permissions: API 金鑰權限 + oidc/trusted_publisher/github_action: + repository_owner_id: + oidc/pending_trusted_publisher: + rubygem_name: errors: messages: - unpwn: - blocked: + unpwn: 曾出現在資料外洩事件中,不應再使用 + blocked: 網域 '%{domain}' 因濫發垃圾郵件而遭封鎖。請使用有效的個人電子郵件地址。 + models: + api_key: + attributes: + expires_at: + inclusion: + organization: + attributes: + handle: + invalid: + ownership: + attributes: + user_id: + already_confirmed: 已是此 Gem 的擁有者 + already_invited: 已獲邀加入此 Gem + ownership_request: + attributes: + user_id: + taken: + existing: + user: + attributes: + handle: + invalid: + version: + attributes: + gem_full_name: + taken: "%{value} 已存在" + full_name: + taken: "%{value} 已存在" + oidc/rubygem_trusted_publisher: + attributes: + rubygem: + taken: + oidc/pending_trusted_publisher: + attributes: + rubygem_name: + unavailable: + models: + user: + api_key: + zero: + one: + other: + activemodel: + attributes: + oidc/provider/configuration: + jwks_uri: JWKS URI + id_token_signing_alg_values_supported: IP 權杖簽署演算法 + errors: + models: + oidc/api_key_permissions: + attributes: + valid_for: + inclusion: "%{value} 秒數應介於 5 分鐘 (300 秒) 和 1 天 (86,400 秒) 之間" + gems: + too_long: api_keys: + form: + exclusive_scopes: + rubygem_scope: + rubygem_scope_info: + multifactor_auth: + enable_mfa: + expiration: create: - success: + success: 已建立新 API 金鑰 + invalid_gem: destroy: - success: + success: 已刪除 API 金鑰:%{name} index: - api_keys: - name: + api_keys: API 金鑰 + name: 名稱 scopes: + gem: Gem age: - last_access: - action: - delete: - confirm: - confirm_all: - new_key: + last_access: 最後存取於 + action: 操作 + delete: 刪除 + confirm: API 金鑰將無效。您確定嗎? + confirm_all: 所有 API 金鑰將無效。您確定嗎? + new_key: 新 API 金鑰 index_rubygems: - push_rubygem: - yank_rubygem: - add_owner: - remove_owner: - access_webhooks: - show_dashboard: - reset: - save_key: + push_rubygem: 推送 rubygem + yank_rubygem: 移除 rubygem + add_owner: 新增擁有者 + remove_owner: 移除擁有者 + update_owner: + access_webhooks: 存取 Webhooks + show_dashboard: 顯示儀表板 + configure_trusted_publishers: + reset: 重設 + save_key: 請注意,我們無法再次顯示您的 API 金鑰。新 API 金鑰: + mfa: MFA + expiration: new: - new_api_key: + new_api_key: 新 API 金鑰 reset: - success: + success: 已刪除所有 API 金鑰 update: - success: + success: 已成功更新 API 金鑰 + invalid_gem: edit: - edit_api_key: - clearance_mailer: - change_password: - title: - subtitle: + edit_api_key: 編輯 API 金鑰 + invalid_key: 無法編輯無效的 API 金鑰。請刪除此金鑰並重新建立。 + all_gems: 所有 Gems + gem_ownership_removed: dashboards: show: creating_link_text: Gem 建立 gem_link_text: Gem 頁面 latest: 最近更新 - latest_title: 最新的 RSS Feed + latest_title: 最近更新 RSS 摘要 migrating_link_text: Gem 轉移 mine: 我的 Gems my_subscriptions: 我的訂閱 - no_owned_html: 你尚未發佈任何 Gem。可以閱讀 %{creating_link} 教學,或參考 %{migrating_link} 教學來將你的 Gem 從 RubyForge 遷移過來。 - no_subscriptions_html: 你還沒有訂閱過 Gem,前往 %{gem_link} 來訂閱! + no_owned_html: 您尚未發佈任何 Gem。可以閱讀 %{creating_link} 教學,或參考 %{migrating_link} 教學來將您的 + Gem 從 RubyForge 遷移過來。 + no_subscriptions_html: 您還沒有訂閱過 Gem,前往 %{gem_link} 來訂閱! title: 控制台 + dependencies: + show: + click_to_expand: 點擊箭頭圖示來展開。 email_confirmations: create: - promise_resend: + promise_resend: 如果該帳號存在,我們會將用於啟用帳號的確認連結傳送到您的電子郵件地址。 new: - submit: 重新發送 - title: 重新發送確認信 - will_email_notice: 我們將會通過 Email 發送帳號認證信連結給你。 + submit: 重新傳送 + title: 重新傳送確認信 + will_email_notice: 我們將會通過電子郵件傳送帳號認證信連結給您。 update: - confirmed_email: Email 驗證成功。 + confirmed_email: 電子郵件地址驗證成功。 + token_failure: 請確認 URL 或再次提交 home: index: downloads_counting_html: 總下載次數 @@ -128,7 +236,7 @@ zh-TW: help: 說明 hosted_by: 托管 monitored_by: 監控 - optimized_by: 優化 + optimized_by: 最佳化 resolved_with: 解析 security: 安全 source_code: 原始碼 @@ -138,74 +246,147 @@ zh-TW: tested_by: 測試 tracking_by: 追蹤 uptime: 上線時間 + verified_by: 驗證 + secured_by: + looking_for_maintainers: 徵求維護者 header: - dashboard: 控制台 - edit_profile: - settings: + dashboard: 儀表板 + settings: 設定 + edit_profile: 編輯個人檔案 search_gem_html: 搜尋 Gems… sign_in: 登入 sign_out: 登出 sign_up: 註冊 + mfa_banner_html: "\U0001F389 我們現在支援安全裝置了!設定新的裝置來提升您的帳號安全。[了解詳情](部落格文章連結)!" mailer: - confirm_your_email: - confirmation_subject: - link_expiration_explanation_html: + confirm_your_email: 已寄送連結,請點擊連結來確認您的電子郵件地址。 + confirmation_subject: "%{host} 電子郵件地址確認" + link_expiration_explanation_html: 請注意,此連結將在 3 小時後過期。您可以前往確認信頁面要求傳送新連結。 email_confirmation: - title: - subtitle: - confirmation_link: - welcome_message: + title: 電子郵件地址確認 + subtitle: 就快完成了! + confirmation_link: 確認電子郵件地址 + welcome_message: 歡迎來到 RubyGems.org!點擊下方的連結來驗證您的電子郵件地址。 email_reset: - title: - subtitle: - visit_link_instructions: + title: 電子郵件地址重設 + subtitle: 嗨 %{handle}! + visit_link_instructions: 您已變更您在 %{host} 的電子郵件地址。請點擊下列網址來重新啟用您的帳號。 deletion_complete: - title: - subtitle: - subject: - body_html: + title: 刪除完成 + subtitle: 掰掰! + subject: 您在 rubygems.org 上的帳號已被刪除 + body_html: 您在 %{host} 的帳號刪除請求已處理完畢。您隨時可以透過 %{sign_up} 頁面建立新帳號。 deletion_failed: - title: - subtitle: - subject: - body_html: + title: 刪除失敗 + subtitle: 抱歉! + subject: 您在 rubygems.org 上的帳號刪除請求失敗 + body_html: 您曾在 rubygems.org 送出帳號刪除的請求。很遺憾,我們無法處理您的請求,請稍後再試。若無法解決,請 %{contact} + 我們。 notifiers_changed: - subject: - title: - subtitle: - 'on': - off_html: + subject: 您已更改 RubyGems.org 的電子郵件通知設定 + title: 電子郵件通知 + subtitle: 嗨 %{handle} + 'on': 開 + off_html: "" gem_pushed: - subject: - title: + subject: Gem %{gem} 已推送至 RubyGems.org + title: Gem 已推送 gem_yanked: - subject: - title: + subject: Gem %{gem} 已從 RubyGems.org 移除 + title: Gem 已移除 reset_api_key: - subject: - title: - subtitle: + subject: RubyGems.org 上的 API 金鑰遭重設 + title: API 金鑰重設 + subtitle: 嗨 %{handle} + webauthn_credential_created: + subject: "%{host} 上的安全裝置已新增" + title: 已新增安全裝置 + subtitle: 嗨 %{handle}! + webauthn_credential_removed: + subject: "%{host} 上的安全裝置已移除" + title: 安全裝置已移除 + subtitle: 嗨 %{handle}! + totp_enabled: + subject: "%{host} 上的驗證器應用程式已啟用" + title: 已啟用驗證器應用程式 + subtitle: 嗨 %{handle}! + totp_disabled: + subject: "%{host} 上的驗證器應用程式已停用" + title: 已停用驗證器應用程式 + subtitle: 嗨 %{handle}! email_reset_update: - subject: - title: + subject: 您已在 %{host} 請求電子郵件地址更新 + title: 已請求電子郵件地址更新 ownership_confirmation: - subject: - title: - subtitle: - body_html: + subject: 請確認 RubyGems.org 上 %{gem} Gem 的所有權 + title: 所有權確認 + subtitle: 嗨 %{handle}! + body_text: "%{authorizer} 把您加入了 %{gem} Gem 的擁有者名單。請點擊下方連結來確認您的所有權。" + body_html: %{authorizer} 把您加入了 %{gem} + Gem 的擁有者名單。請點擊下方連結來確認您的所有權。 link_expiration_explanation_html: owner_added: - subject_self: - subject_others: + subject_self: 您加入了 %{gem} Gem 的擁有者名單 + subject_others: 使用者 %{owner_handle} 加入了 %{gem} Gem 的擁有者名單 title: - subtitle: + subtitle: 嗨 %{user_handle}! body_self_html: body_others_html: owner_removed: + subject: + title: + subtitle: 嗨 %{user_handle}! + body_html: + owner_updated: subject: title: subtitle: body_html: + body_text: + ownerhip_request_closed: + title: 所有權請求 + subtitle: 嗨 %{handle}! + body_html: 感謝您申請 %{gem} 的所有權。我們很遺憾地通知您,您的所有權請求已被 Gem 擁有者關閉。 + ownerhip_request_approved: + body_html: 恭喜!您對 %{gem} 的所有權請求已通過。您已加入 Gem 的擁有者名單。 + new_ownership_requests: + body_html: + zero: + one: + other: "%{gem}%{count} 項新所有權請求。請點擊下方按鈕來查看所有請求。" + button: 所有權請求 + disable_notifications: 若要停止接收這些訊息,請更新您的 + owners_page: 所有權 + web_hook_deleted: + title: Webhook 遭刪除 + subject: 您在 RubyGems.org 上的 Webhook 遭到刪除 + subtitle: 嗨 %{handle}! + body_text: 您傳送 POST 請求至 %{url} 的 Webhook 在 %{failures} 次失敗後遭到刪除。 + body_html: 您傳送 POST 請求至 %{url} + 的 Webhook 在 %{failures} 次失敗後遭到刪除。 + global_text: 之前這個 Webhook 會在任何 Gem 被推送的時候被呼叫。 + global_html: 之前這個 Webhook 會在任何 Gem 被推送的時候被呼叫。 + gem_text: 之前這個 Webhook 會在 %{gem} 被推送的時候被呼叫。 + gem_html: 之前這個 Webhook 會在 %{gem} + 被推送的時候被呼叫。 + web_hook_disabled: + title: Webhook 遭停用 + subject: 您在 RubyGems.org 上的 Webhook 遭到停用 + subtitle: 嗨 %{handle}! + body_text: | + 您傳送 POST 請求至 %{url} 的 Webhook 因 %{disabled_reason} 被停用。 + 該 Webhook 最後成功於 %{last_success},之後便失敗了 %{failures_since_last_success} 次。 + 您可以執行 `%{delete_command}` 命令來刪除此 Webhook。 + body_html: + global_text: 之前這個 Webhook 會在任何 Gem 被推送的時候被呼叫。 + global_html: 之前這個 Webhook 會在任何 Gem 被推送的時候被呼叫。 + gem_text: 之前這個 Webhook 會在 %{gem} 被推送的時候被呼叫。 + gem_html: 之前這個 Webhook 會在 %{gem} + 被推送的時候被呼叫。 + gem_trusted_publisher_added: + title: + admin_manual: + title: news: show: title: 最新發佈 @@ -215,9 +396,21 @@ zh-TW: title: 熱門新發佈 pages: about: - founding_html: 本專案由 %{founder} 於 2009 年 4 月創立,發展過程中有超過 %{contributors} 貢獻者以及 %{downloads}。自 RubyGems 1.3.6 發佈以來,本站名稱由 Gemcutter 更名為 %{title},本站自此之後成為 Ruby 社群的核心網站。 - support_html: 雖然 Gemcutter 並不是由一個特定的公司運作,但在發展過程中接受了許多來源的幫助。目前的設計、圖像以及網站的前端開發是由 %{dockyard} 提供。%{github} 也幫助我們能更容易地協作和分享原始碼。本站部署在 %{heroku} 上,其一流的服務,更有助於證明 Gemcutter 是一個可以穩定、可行的解決方案。Our infrastructure is currently hosted on %{aws}. - technical_html: 關於本站的技術資訊:100% Ruby。主站是一個 %{rails} 應用程式。Gems 架設在 %{s3} 上, served by %{fastly}, 使得 Gem 從發佈到提供下載的時間大幅縮短。詳細資訊可從 GitHub 上的 %{source_code} 查看(遵守 %{license} 協議)。 + contributors_amount: "%{count} 位 Ruby 愛好者" + downloads_amount: + checkout_code: + mit_licensed: MIT 授權 + logo_header: + logo_details: 只要點選下載按鈕即可獲得三份 .PNG 和一份 .SVG 格式的 RubyGems Logo。 + founding_html: 本專案由 %{founder} 於 2009 年 4 月創立,發展過程中有超過 %{contributors} 貢獻者以及 + %{downloads} 次下載。自 RubyGems 1.3.6 發佈以來,本站名稱由 Gemcutter 更名為 %{title},本站自此之後成為 + Ruby 社群的核心網站。 + support_html: 雖然 RubyGems.org 並不是由一個特定的公司運作,但在發展過程中接受了許多來源的幫助。目前的設計、圖像以及網站的前端開發是由 + %{dockyard} 提供。%{github} 也幫助我們能更容易地協作和分享原始碼。本站部署在 %{heroku} 上,其一流的服務,更有助於證明 + RubyGems.org 是一個可以穩定、可行的解決方案。Our infrastructure is currently hosted on %{aws}. + technical_html: 關於本站的技術資訊:100% Ruby。主站是一個 %{rails} 應用程式。Gems 架設在 %{s3} 上, served + by %{fastly}, 使得 Gem 從發佈到提供下載的時間大幅縮短。詳細資訊可從 GitHub 上的 %{source_code} 查看(遵守 + %{license} 協議)。 title: 關於 purpose: better_api: 提供更好的 Gem API @@ -227,147 +420,239 @@ zh-TW: data: title: download: - title: + title: 下載 RubyGems faq: - title: + title: 常見問題 migrate: - title: + title: 轉移 Gems security: - title: + title: 安全性 sponsors: + title: 贊助者 + password_mailer: + change_password: + closing: + opening: title: + subtitle: passwords: edit: submit: 更新密碼 title: 重設密碼 + token_failure: 請確認 URL 或再次提交 new: submit: 更新密碼 title: 修改密碼 - will_email_notice: 系統將會寄一封包含重設密碼連結的電子郵件給你 - otp_prompt: - authenticate: - multifactor_auths: - incorrect_otp: - otp_code: - require_mfa_disabled: - require_mfa_enabled: - new: - title: - scan_prompt: - otp_prompt: - confirm: - enable: - account: - key: - time_based: + will_email_notice: 系統將會寄一封包含重設密碼連結的電子郵件給您 create: - qrcode_expired: success: + failure_on_missing_email: + update: + failure: + multifactor_auths: + session_expired: 您的登入頁面工作階段已過期。 + require_mfa_enabled: 您尚未啟用多重要素驗證,請先啟用。 + require_webauthn_enabled: 您未啟用任何安全裝置。您需要先將裝置綁定到您的帳號。 + setup_required_html: 您必須設定多重要素驗證以保護您的帳號和 Gems。請閱讀我們的部落格文章以了解詳情。 + setup_recommended: 為保護您的帳號和 Gems,我們建議您設定多重要素驗證。未來我們將強制要求所有帳號啟用 MFA。 + strong_mfa_level_required_html: 為保護您的帳號和 Gems,您必須將您的 MFA 等級設為 "使用者介面和 Gem 登入" + 或 "使用者介面和 API"。請閱讀我們的部落格文章以了解詳情。 + strong_mfa_level_recommended: 為保護您的帳號和 Gems,我們建議您將您的 MFA 等級設為 "使用者介面和 Gem 登入" + 或 "使用者介面和 API"。未來我們將強制要求所有帳號將設為上述 MFA 等級。 + setup_webauthn_html: "\U0001F389 我們現在支援安全裝置了!設定新的裝置來提升您的帳號安全。了解詳情!" + api: + mfa_required: + mfa_required_not_yet_enabled: + mfa_required_weak_level_enabled: + mfa_recommended_not_yet_enabled: + mfa_recommended_weak_level_enabled: recovery: - continue: - title: - note: - destroy: - success: + continue: 繼續 + title: 復原碼 + saved: + confirm_dialog: + note_html: + already_generated: update: - success: + invalid_level: MFA 等級無效。 + success: 您已成功修改多重驗證等級。 + prompt: + webauthn_credential_note: + sign_in_with_webauthn_credential: + otp_code: OTP 碼 + otp_or_recovery: OTP 或復原碼 + recovery_code: 復原碼 + recovery_code_html: + security_device: 安全裝置 + verify_code: 驗證碼 + totps: + incorrect_otp: 您的 OTP 碼不正確。 + require_totp_disabled: 您基於 OTP 的多重要素驗證已經啟用。您需要先移除才能重新設定。 + require_totp_enabled: 您未啟用驗證器應用程式。請先啟用。 + new: + title: 啟用多重要素驗證 + scan_prompt: 請用您的驗證裝置掃描 QR-code。如果您沒辦法掃描,手動輸入下面的資料。 + otp_prompt: 輸入驗證裝置上的數字來繼續。 + confirm: 我已把復原碼收在安全的地方。 + enable: 啟用 + account: 帳號:%{account} + key: 金鑰:%{key} + time_based: 基於時間:是 + create: + qrcode_expired: QR-code 和金鑰已過期。請重新註冊裝置。 + success: 您已成功啟用基於 OTP 的多重要素驗證。 + destroy: + success: 您已成功停用基於 OTP 的多重要素驗證。 notifiers: update: - success: + success: 您已成功更新您的電子郵件通知設定。 show: - info: - 'on': - 'off': - recommended: - title: - update: - push_heading: - owner_heading: + info: 為協助偵測未經授權的 Gem 或所有權變更,我們將在您擁有的 Gem 推送新版本、遭移除,或新增擁有者時傳送電子郵件給您。在接收和閱讀這些郵件的同時,您也在保護 + Ruby 的生態圈。 + 'on': 開 + 'off': 關 + recommended: 建議 + title: 電子郵件通知 + update: 更新 + owner_heading: 所有權通知 + owner_request_heading: 所有權請求通知 + push_heading: 推送通知 + webauthn_verifications: + expired_or_already_used: 您所使用的連結中的權杖已過期或被使用。 + no_port: 未提供連接埠。請再試一次。 + pending: + prompt: + title: 透過安全裝置驗證 + authenticating_as: 驗證身分為 + authenticate: 驗證 + no_webauthn_devices: 您未啟用任何安全裝置 + successful_verification: + title: 成功! + close_browser: 請關閉此瀏覽器。 + failed_verification: + title: 錯誤 - 驗證失敗 + close_browser: 請關閉此瀏覽器並重試。 owners: confirm: confirmed_email: - token_expired: + token_expired: 確認權杖已過期。請從 Gem 頁面嘗試重新傳送權杖。 index: - add_owner: - name: - mfa: - status: - confirmed_at: - added_by: - action: - info: + add_owner: 新增擁有者 + name: 擁有者 + mfa: MFA 狀態 + status: 狀態 + confirmed_at: 確認於 + added_by: 新增者 + action: 操作 email_field: - submit_button: - confirmed: - pending: - confirm_remove: + submit_button: 新增擁有者 + info: 新增或移除擁有者 + confirmed: 已確認 + pending: 待確認 + confirm_remove: 您確定要將此使用者從擁有者名單中移除嗎? + role: + role_field: resend_confirmation: resent_notice: create: - success_notice: + success_notice: "%{handle} 已以未確認擁有者的身分加入。所有權存取將在使用者點擊傳送到他們的電子郵件地址的確認信後啟用。" destroy: removed_notice: - failed_notice: - settings: + failed_notice: 無法移除 Gem 的唯一擁有者 edit: + role: title: + update: + update_current_user_role: + success_notice: + mfa_required: Gem 啟用了 MFA 要求,請設定您的帳號的 MFA。 + settings: + edit: + title: 編輯設定 + webauthn_credentials: 安全裝置 + no_webauthn_credentials: 您沒有任何安全裝置 + webauthn_credential_note: 安全裝置可以是任何遵循 FIDO2 標準的裝置,例如安全或生物特徵金鑰。 + otp_code: OTP 或復原碼 api_access: - confirm_reset: 確定要重設嗎?此動作無法還原 - credentials_html: 如果你需要從 command line 中執行 %{gem_commands_link},你需要先準備 %{gem_credentials_file} 這個檔案,用以下的指令可以產生: - key_is_html: 你的 API key 是 %{key}。 - link_text: Gem 指令 - reset: 重設 API key + confirm_reset: 確定要重設嗎?此動作無法還原。 + credentials_html: 如果您需要從命令列中執行 %{gem_commands_link},您需要先準備 %{gem_credentials_file} + 這個檔案,可以使用以下的命令產生: + key_is_html: 您的 API key 是 %{key}。 + link_text: Gem 命令 + reset: 重設我的 API 金鑰 + reset_all: title: API 存取 reset_password: title: 重設密碼 mfa: - multifactor_auth: - disabled: - go_settings: - enabled: - update: + multifactor_auth: 多重要素驗證 + otp: 驗證器應用程式 + disabled_html: 您尚未啟用基於 OTP 的多重要素驗證。請參閱 RubyGems + MFA 指南來了解關於 MFA 等級的資訊。 + go_settings: 註冊新裝置 + level_html: 您已啟用多重要素驗證。請點擊 '更新' 來變更您的 MFA 等級。請參閱 RubyGems + MFA 指南來了解關於 MFA 等級的資訊。 + enabled_note: 您已啟用多重要素驗證。請輸入驗證器提供的 OTP 碼或復原碼來停用。 + update: 更新 + disable: 停用 + enabled: 已啟用 + disabled: 已停用 level: - title: - disabled: - ui_only: - ui_and_api: - ui_and_gem_signin: + title: 多重驗證等級 + disabled: 停用 + ui_only: 僅使用者介面 + ui_and_api: 使用者介面與 API (建議) + ui_and_gem_signin: 使用者介面和 Gem 登入 profiles: + adoptions: + no_ownership_calls: + no_ownership_requests: 您尚未建立任何所有權請求。 + title: 認養 + subtitle_html: 請求新維護者或所有權 (了解詳情) edit: change_avatar: - email_awaiting_confirmation: 請驗證你的新 Email %{unconfirmed_email} + disabled_avatar_html: + email_awaiting_confirmation: 請確認您的新電子郵件地址 %{unconfirmed_email} enter_password: 輸入密碼 - hide_email: 在公開的個人頁面中隱藏 email - optional_twitter_username: Twitter 帳號(可選) + optional_full_name: 選填。將公開顯示 + optional_twitter_username: X 帳號(可選) + twitter_username: 帳號 title: 編輯個人檔案 delete: delete: 刪除 - delete_profile: 刪除個人資料 + delete_profile: 刪除個人檔案 warning: 警告 delete: - title: - confirm: - instructions: - list_only_owner_html: - list_multi_owner: + title: 刪除個人檔案 + confirm: 確認 + instructions: 我們很遺憾 + list_only_owner_html: 這些 Gems 將與您的個人檔案一起被移除。如果您想要在刪除個人檔案前新增擁有者,請使用 %{command_link} + 命令。 + list_multi_owner: 您將失去這些 Gem 的存取權,但其他擁有者將不受影響。 warning: 警告 rubygem: - owners_header: + owners_header: 擁有者 destroy: request_queued: update: - confirmation_mail_sent: - updated: - request_denied: + confirmation_mail_sent: 您將在幾分鐘內收到一封郵件。內含確認您新的電子郵件地址的指示。 + updated: 您的個人檔案已更新。 + public_email: 在公開個人檔案顯示電子郵件地址 + request_denied: 此請求遭拒。我們無法驗證您的密碼。 + show: + title: "%{username} 的個人檔案" + security_events: + title: + description_html: rubygems: aside: - bundler_header: Gemfile - copied: 已複製 - copy_to_clipboard: 複製 downloads_for_this_version: 這個版本 - install: 安裝 + gem_version_age: 版本发布 required_ruby_version: Ruby 版本需求 required_rubygems_version: RubyGems 版本需求 + requires_mfa: 新版本需要 MFA + released_with_mfa: 使用 MFA 發布的版本 links: badge: 徽章 bugs: Bug 追蹤 @@ -375,107 +660,324 @@ zh-TW: code: 原始碼 docs: 文件 download: 下載 + funding: header: 相關連結 home: 首頁 mail: 郵件群組 - report_abuse: 舉報投訴 + report_abuse: 檢舉濫用 reverse_dependencies: 反向依賴 review_changes: rss: RSS subscribe: 訂閱 unsubscribe: 取消訂閱 wiki: Wiki - ownership: - resend_ownership_confirmation: - blacklisted: - blacklisted_namespace: + resend_ownership_confirmation: 重新傳送確認信 + ownership: 所有權 + oidc: + api_key_role: + name: + new: + trusted_publishers: + reserved: + reserved_namespace: 此命名空間被 rubygems.org 保留。 dependencies: header: "%{title} 相依性套件" gem_members: authors_header: 作者 - not_using_mfa_warning_show: - not_using_mfa_warning_hide: + self_no_mfa_warning_html: + not_using_mfa_warning_show: "* 某些擁有者未使用多重要素驗證 (MFA)。點擊此處以顯示完整名單。" + not_using_mfa_warning_hide: "* 下列擁有者未使用多重要素驗證 (MFA)。點擊此處以隱藏。" owners_header: 擁有者 - pushed_by: - using_mfa_info: - yanked_by: - sha_256_checksum: SHA 256 checksum + pushed_by: 推送者 + using_mfa_info: "* 擁有者皆使用多重要素驗證 (MFA)。" + yanked_by: 移除者 + sha_256_checksum: SHA 256 總和檢查碼 + signature_period: 簽名有效期 + expired: 已過期 + version_navigation: + previous_version: "← 上一版本" + next_version: 下一版本 → index: downloads: 下載 title: Gems show: + bundler_header: Gemfile + install: 安裝 licenses_header: - one: License - other: License + one: 授權 + other: 授權 no_licenses: 無 requirements_header: 必填 show_all_versions: 顯示所有版本(共 %{count}) versions_header: 版本列表 yanked_notice: 這個 Gem 版本已被移除,因此無法提供下載,也無法被其他的 Gem 相依。 show_yanked: - not_hosted_notice: 這個 Gem 目前沒有在 Gemcutter 上 + not_hosted_notice: 這個 Gem 目前沒有在 RubyGems.org 上 reserved_namespace_html: one: other: + security_events: + title: + description_html: reverse_dependencies: index: - title: - subtitle: + title: "%{name} 的反向依賴" + subtitle: 下列 Gems 的最新版本需要 %{name} + no_reverse_dependencies: 此 Gem 沒有反向依賴。 search: - search_reverse_dependencies_html: + search_reverse_dependencies_html: 搜尋反向依賴 Gems… searches: advanced: name: 名稱 summary: 摘要 description: 描述 downloads: 下載數 - updated: 最後更新時間 + updated: 更新於 + yanked: 移除於 show: - subtitle: "%{query}" - month_update: - week_update: + subtitle_html: "%{query}" + month_update: 於最近一個月更新 (%{count}) + week_update: 於最近一週更新 (%{count}) filter: - yanked: - suggestion: + yanked: 遭移除 (%{count}) + suggestion: 您是不是在找 sessions: new: forgot_password: 忘記密碼? resend_confirmation: 沒收到確認信? verify: - title: - confirm: - notice: + title: 確認密碼 + confirm: 確認 + notice: 請確認您的密碼以繼續。 + create: + account_blocked: stats: index: - title: + title: 統計資料 all_time_most_downloaded: 歷史下載次數排行 total_downloads: 總下載次數 total_gems: Gems 總數 total_users: 總使用者數量 users: create: - email_sent: 已發送確認信到你的信箱地址。 + email_sent: 已傳送確認信到您的電子郵件地址。 new: have_account: 已經註冊過了? versions: index: - not_hosted_notice: 這個 Gem 目前沒有在 Gemcutter 上 + not_hosted_notice: 這個 Gem 目前沒有在 RubyGems.org 上 title: "%{name} 的所有版本" - versions_since: 自從 %{since} 以來,有 %{count} 個版本 + versions_since: + other: 自從 %{since} 以來,有 %{count} 個版本 + one: 自從 %{since} 以來,有 %{count} 個版本 + imported_gem_version_notice: 此版本的 Gem 於 %{import_date} 匯入 RubyGems.org。顯示日期由作者在 + gemspec 指定。 version: yanked: 已被移除 - will_paginate: - next_label: 下一頁 - page_gap: "…" - previous_label: 上一頁 - page_entries_info: - multi_page: 顯示所有 %{count} %{model}中的%{from}-%{to} 條 - multi_page_html: 顯示所有 %{count} %{model}中的%{from} - %{to} 條 - single_page: - one: 顯示 1 %{model} - other: 顯示所有 %{count} %{model} - zero: 找不到 %{model} - single_page_html: - one: 顯示 1 %{model} - other: 顯示 所有 %{count} %{model} - zero: 找不到 %{model} + adoptions: + index: + title: 認養 + subtitle_owner_html: 請求新的維護者加入 %{gem} (read + more) + subtitle_user_html: + ownership_calls: + no_ownership_calls: + ownership_calls: + update: + success_notice: + create: + success_notice: + index: + title: 徵求維護者 + subtitle_html: RubyGems 正在尋找新的維護者加入團隊 (了解詳情) + share_requirements: 請說明您需要哪方面的協助 + note_for_applicants: + created_by: 建立者 + details: + apply: 申請 + close: 關閉 + markup_supported_html: 支援 + Rdoc 標記語言 + create_call: + ownership_requests: + create: + success_notice: 您的所有權請求已送出。 + update: + approved_notice: 所有權請求已核准。%{name} 已加入擁有者名單。 + closed_notice: + close: + success_notice: + ownership_requests: 所有權請求 + note_for_owners: + your_ownership_requests: 您的所有權請求 + close_all: 全部關閉 + approve: + gems_published: 已發布的 Gems + created_at: 建立於 + no_ownership_requests: 您的專案的加入請求將在此顯示。 + create_req: 建立所有權請求 + signin_to_create_html: + webauthn_credentials: + callback: + success: 您已成功註冊安全裝置。 + recovery: + continue: 繼續 + title: 您已成功新增安全裝置 + notice_html: + saved: + webauthn_credential: + confirm_delete: 已刪除認證 + delete_failed: 無法刪除認證 + delete: 刪除 + confirm: 您確定要刪除此認證嗎? + saved: 已成功建立安全裝置 + form: + new_device: 註冊新安全裝置 + nickname: 暱稱 + submit: 註冊裝置 + oidc: + api_key_roles: + index: + api_key_roles: + new_role: + show: + api_key_role_name: + automate_gh_actions_publishing: + view_provider: + edit_role: + delete_role: + confirm_delete: + deleted_at_html: + edit: + edit_role: + git_hub_actions_workflow: + title: + configured_for_html: + to_automate_html: + not_github: + not_push: + a_gem: + instructions_html: + new: + title: + update: + success: + create: + success: + destroy: + success: + form: + add_condition: + remove_condition: + add_statement: + remove_statement: + deleted: + providers: + index: + title: + description_html: + show: + title: + id_tokens: + index: + title: + show: + title: + rubygem_trusted_publishers: + index: + title: + subtitle_owner_html: + delete: + create: + description_html: + destroy: + success: + create: + success: + new: + title: + subtitle_owner_html: + pending_trusted_publishers: + index: + title: + valid_for_html: + delete: + create: + description_html: + destroy: + success: + create: + success: + new: + title: + trusted_publisher: + unsupported_type: + github_actions: + repository_owner_help_html: + repository_name_help_html: + workflow_filename_help_html: + environment_help_html: + pending: + rubygem_name_help_html: + duration: + minutes: + other: + one: + hours: + other: + one: + days: + other: + one: + seconds: + other: + one: + form: + optional: + events: + table_component: + event: + time: + additional_info: + redacted: + no_user_agent_info: + rubygem_event: + version: + version_pushed: + version_yanked: + version_unyanked: + version_html: + version_pushed_sha256_html: + version_pushed_by_html: + version_yanked_by_html: + owner: + owner_added: + owner_added_owner_html: + owner_added_authorizer_html: + owner_removed: + owner_removed_owner_html: + owner_removed_by_html: + owner_confirmed: + user_event: + user: + created: + email: + login: + login_success: + webauthn_login: + mfa_method: + mfa_device: + none: + email: + email_verified: + email_sent_subject: + email_sent_from: + email_sent_to: + api_key: + api_key_created: + api_key_deleted: + api_key_name: + api_key_scopes: + api_key_gem_html: + api_key_mfa: + not_required: \ No newline at end of file diff --git a/config/newrelic.yml b/config/newrelic.yml deleted file mode 100644 index 10fbbc8561b..00000000000 --- a/config/newrelic.yml +++ /dev/null @@ -1,45 +0,0 @@ -# -# This file configures the New Relic Agent. New Relic monitors Ruby, Java, -# .NET, PHP, Python, Node, and Go applications with deep visibility and low -# overhead. For more information, visit www.newrelic.com. -# -# Generated September 29, 2018 -# -# This configuration file is custom generated for rubygems -# -# For full documentation of agent configuration options, please refer to -# https://docs.newrelic.com/docs/agents/ruby-agent/installation-configuration/ruby-agent-configuration - -common: &default_settings - # Required license key associated with your New Relic account. - license_key: '<%= ENV["NEW_RELIC_LICENSE_KEY"] %>' - # Your application name. Renaming here affects where data displays in New - # Relic. For more details, see https://docs.newrelic.com/docs/apm/new-relic-apm/maintenance/renaming-applications - app_name: RubyGems.org - - # To disable the agent regardless of other settings, uncomment the following: - # agent_enabled: false - - # Logging level for log/newrelic_agent.log - log_level: info - - -# Environment-specific settings are in this section. -# RAILS_ENV or RACK_ENV (as appropriate) is used to determine the environment. -# If your application has other named environments, configure them here. -development: - <<: *default_settings - monitor_mode: false - -test: - <<: *default_settings - # It doesn't make sense to report to New Relic from automated test runs. - monitor_mode: false - -staging: - <<: *default_settings - app_name: RubyGems.org (Staging) - -production: - <<: *default_settings - app_name: RubyGems.org (Production) diff --git a/config/pghero.yml b/config/pghero.yml new file mode 100644 index 00000000000..73a9728b7e7 --- /dev/null +++ b/config/pghero.yml @@ -0,0 +1,52 @@ +databases: + primary: + # Database URL (defaults to app database) + # url: <%= ENV["DATABASE_URL"] %> + + # System stats + # aws_db_instance_identifier: my-instance + # gcp_database_id: my-project:my-instance + # azure_resource_id: my-resource-id + + # Add more databases + # other: + # url: <%= ENV["OTHER_DATABASE_URL"] %> + +# Minimum time for long running queries +# long_running_query_sec: 60 + +# Minimum average time for slow queries +# slow_query_ms: 20 + +# Minimum calls for slow queries +# slow_query_calls: 100 + +# Minimum connections for high connections warning +# total_connections_threshold: 500 + +# Explain functionality +explain: analyze + +# Statement timeout for explain +# explain_timeout_sec: 10 + +# Visualize URL for explain +# visualize_url: https://... + +# Time zone (defaults to app time zone) +# time_zone: "Pacific Time (US & Canada)" + +# Basic authentication +# username: admin +# password: <%= ENV["PGHERO_PASSWORD"] %> + +# Stats database URL (defaults to app database) +# stats_database_url: <%= ENV["PGHERO_STATS_DATABASE_URL"] %> + +# AWS configuration (defaults to app AWS config) +# aws_access_key_id: <%= ENV["AWS_ACCESS_KEY_ID"] %> +# aws_secret_access_key: <%= ENV["AWS_SECRET_ACCESS_KEY"] %> +# aws_region: us-east-1 + +# Filter data from queries (experimental) +# filter_data: true diff --git a/config/puma.rb b/config/puma.rb new file mode 100644 index 00000000000..06bcbfdb6d8 --- /dev/null +++ b/config/puma.rb @@ -0,0 +1,69 @@ +# This configuration file will be evaluated by Puma. The top-level methods that +# are invoked here are part of Puma's configuration DSL. For more information +# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. + +# Puma starts a configurable number of processes (workers) and each process +# serves each request in a thread from an internal thread pool. +# +# The ideal number of threads per worker depends both on how much time the +# application spends waiting for IO operations and on how much you wish to +# to prioritize throughput over latency. +# +# As a rule of thumb, increasing the number of threads will increase how much +# traffic a given process can handle (throughput), but due to CRuby's +# Global VM Lock (GVL) it has diminishing returns and will degrade the +# response time (latency) of the application. +# +# The default is set to 3 threads as it's deemed a decent compromise between +# throughput and latency for the average Rails application. +# +# Any libraries that use a connection pool or another resource pool should +# be configured to provide at least as many connections as the number of +# threads. This includes Active Record's `pool` parameter in `database.yml`. +threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) +threads threads_count, threads_count + +rails_env = ENV.fetch("RAILS_ENV") { "development" } +production_like = !%w[development test].include?(rails_env) # rubocop:disable Rails/NegateInclude + +require "concurrent" + +if production_like + # Specifies that the worker count should equal the number of processors in production. + worker_count = Integer(ENV.fetch("WEB_CONCURRENCY") { Concurrent.physical_processor_count }) + workers worker_count if worker_count > 1 + worker_timeout 60 +else + # Specifies the `worker_timeout` threshold that Puma will use to wait before + # terminating a worker in development environments. + worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" + + # Allow puma to be restarted by `bin/rails restart` command. + plugin :tmp_restart + + # Run tailwindcss:watch in the background + plugin :tailwindcss +end + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +port ENV.fetch("PORT", 3000) + +# Specifies the `environment` that Puma will run in. +environment rails_env + +# Specify the PID file. Defaults to tmp/pids/server.pid in development. +# In other environments, only set the PID file if requested. +pidfile ENV["PIDFILE"] if ENV["PIDFILE"] + +before_fork do + sleep 1 +end + +on_worker_boot do + # Re-open appenders after forking the process. https://logger.rocketjob.io/forking.html + SemanticLogger.reopen +end + +on_restart do + Rails.configuration.launch_darkly_client&.close +end diff --git a/config/routes.rb b/config/routes.rb index 612de9f5f73..03dd015b3d2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -11,15 +11,28 @@ namespace :v2 do resources :rubygems, param: :name, only: [], constraints: { name: Patterns::ROUTE_PATTERN } do resources :versions, param: :number, only: :show, constraints: { - number: /#{Gem::Version::VERSION_PATTERN}(?=\.json\z)|#{Gem::Version::VERSION_PATTERN}(?=\.yaml\z)|#{Gem::Version::VERSION_PATTERN}/ - } + number: /#{Gem::Version::VERSION_PATTERN}(?=\.(json|yaml|sha256)\z)|#{Gem::Version::VERSION_PATTERN}/o + } do + resources :contents, only: :index, constraints: { + version_number: /#{Gem::Version::VERSION_PATTERN}/o, + format: /json|yaml|sha256/ + } + end end end namespace :v1 do - resource :api_key, only: %i[show create update] + resource :api_key, only: %i[show create update] do + collection do + post :revoke, to: "github_secret_scanning#revoke", defaults: { format: :json } + end + end resource :multifactor_auth, only: :show + resource :webauthn_verification, only: :create do + get ':webauthn_token/status', action: :status, as: :status, constraints: { format: :json } + end resources :profiles, only: :show + get "profile/me", to: "profiles#me" resources :downloads, only: :index do get :top, on: :collection get :all, on: :collection @@ -74,7 +87,8 @@ delete :yank, to: "deletions#create" end constraints rubygem_id: Patterns::ROUTE_PATTERN do - resource :owners, only: %i[show create destroy] + resource :owners, only: %i[show create edit update destroy] + resources :trusted_publishers, controller: 'oidc/rubygem_trusted_publishers', only: %i[index create destroy show] end end @@ -85,16 +99,32 @@ end end - resource :search, only: :show + resource :search, only: :show do + get :autocomplete + end resources :web_hooks, only: %i[create index] do collection do delete :remove post :fire + post :hook_relay_report, to: 'hook_relay#report', defaults: { format: :json } end end resources :timeframe_versions, only: :index + + namespace :oidc do + post 'trusted_publisher/exchange_token' + resources :api_key_roles, only: %i[index show], param: :token, format: 'json', defaults: { format: :json } do + member do + post :assume_role + end + end + + resources :providers, only: %i[index show], format: 'json', defaults: { format: :json } + + resources :id_tokens, only: %i[index show], format: 'json', defaults: { format: :json } + end end end @@ -128,9 +158,17 @@ end resource :dashboard, only: :show, constraints: { format: /html|atom/ } resources :profiles, only: :show - resource :multifactor_auth, only: %i[new create update] + get "profile/me", to: "profiles#me", as: :my_profile + resource :multifactor_auth, only: %i[update] do + get 'recovery' + post 'otp_update', to: 'multifactor_auths#otp_update', as: :otp_update + post 'webauthn_update', to: 'multifactor_auths#webauthn_update', as: :webauthn_update + end + resource :totp, only: %i[new create destroy] resource :settings, only: :edit resource :profile, only: %i[edit update] do + get :adoptions + get :security_events member do get :delete delete :destroy, as: :destroy @@ -139,6 +177,18 @@ resources :api_keys do delete :reset, on: :collection end + + namespace :oidc do + resources :api_key_roles, param: :token do + member do + get 'github_actions_workflow' + end + end + resources :api_key_roles, param: :token, only: %i[show], constraints: { format: :json } + resources :id_tokens, only: %i[index show] + resources :providers, only: %i[index show] + resources :pending_trusted_publishers, except: %i[show edit update] + end end resources :stats, only: :index get "/news" => 'news#show', as: 'legacy_news_path' @@ -151,6 +201,8 @@ only: %i[index show], path: 'gems', constraints: { id: Patterns::ROUTE_PATTERN, format: /html|atom/ } do + get :security_events, on: :member + resource :subscription, only: %i[create destroy], constraints: { format: :js }, @@ -159,43 +211,87 @@ get '/dependencies', to: 'dependencies#show', constraints: { format: /json|html/ } end resources :reverse_dependencies, only: %i[index] - resources :owners, only: %i[index destroy create], param: :handle do + resources :owners, only: %i[index destroy edit update create], param: :handle do get 'confirm', to: 'owners#confirm', as: :confirm, on: :collection get 'resend_confirmation', to: 'owners#resend_confirmation', as: :resend_confirmation, on: :collection end + resource :ownership_calls, only: %i[update create] do + patch 'close', to: 'ownership_calls#close', as: :close, on: :collection + end + resources :ownership_requests, only: %i[create update] do + patch 'close_all', to: 'ownership_requests#close_all', as: :close_all, on: :collection + end + resources :adoptions, only: %i[index] + resources :trusted_publishers, controller: 'oidc/rubygem_trusted_publishers', only: %i[index create destroy new] + end + + resources :ownership_calls, only: :index + resources :webauthn_credentials, only: :destroy + resource :webauthn_verification, only: [] do + get 'successful_verification' + get 'failed_verification' + get ':webauthn_token', to: 'webauthn_verifications#prompt', as: '' end ################################################################################ # Clearance Overrides and Additions resource :email_confirmations, only: %i[new create] do - get 'confirm/:token', to: 'email_confirmations#update', as: :update + get 'confirm', to: 'email_confirmations#update', as: :update + post 'otp_update', to: 'email_confirmations#otp_update', as: :otp_update + post 'webauthn_update', to: 'email_confirmations#webauthn_update', as: :webauthn_update patch 'unconfirmed' end - resources :passwords, only: %i[new create] + resource :password, only: %i[new create edit update] do + post 'otp_edit', to: 'passwords#otp_edit', as: :otp_edit + post 'webauthn_edit', to: 'passwords#webauthn_edit', as: :webauthn_edit + end resource :session, only: %i[create destroy] do - post 'mfa_create', to: 'sessions#mfa_create', as: :mfa_create + post 'otp_create', to: 'sessions#otp_create', as: :otp_create + post 'webauthn_create', to: 'sessions#webauthn_create', as: :webauthn_create + post 'webauthn_full_create', to: 'sessions#webauthn_full_create', as: :webauthn_full_create get 'verify', to: 'sessions#verify', as: :verify post 'authenticate', to: 'sessions#authenticate', as: :authenticate + post 'webauthn_authenticate', to: 'sessions#webauthn_authenticate', as: :webauthn_authenticate end - resources :users, only: %i[new create] do - resource :password, only: %i[create edit update] do - post 'mfa_edit', to: 'passwords#mfa_edit', as: :mfa_edit - end - end + resources :users, only: %i[new create] - get '/sign_in' => 'clearance/sessions#new', as: 'sign_in' - delete '/sign_out' => 'clearance/sessions#destroy', as: 'sign_out' + get '/sign_in' => 'sessions#new', as: 'sign_in' + delete '/sign_out' => 'sessions#destroy', as: 'sign_out' get '/sign_up' => 'users#new', as: 'sign_up' if Clearance.configuration.allow_sign_up? end ################################################################################ - # high_voltage static routes - get 'pages/*id' => 'high_voltage/pages#show', constraints: { id: /(#{HighVoltage.page_ids.join("|")})/ }, as: :page + # UI API + + scope constraints: { format: :json }, defaults: { format: :json } do + resources :webauthn_credentials, only: :create do + post :callback, on: :collection + end + end + + scope constraints: { format: :text }, defaults: { format: :text } do + resource :webauthn_verification, only: [] do + post ':webauthn_token', to: 'webauthn_verifications#authenticate', as: :authenticate + end + end + + ################################################################################ + # UI Images + + scope constraints: { format: /jpe?g/ }, defaults: { format: :jpeg } do + resources :users, only: [] do + get 'avatar', on: :member, to: 'avatars#show', format: true + end + end + + ################################################################################ + # static pages routes + get 'pages/*id' => 'pages#show', constraints: { format: :html, id: Regexp.union(Gemcutter::PAGES) }, as: :page ################################################################################ # Internal Routes @@ -207,5 +303,42 @@ ################################################################################ # Incoming Webhook Endpoint - resources :sendgrid_events, only: :create, format: false, defaults: { format: :json } + + if Rails.env.local? || (ENV['SENDGRID_WEBHOOK_USERNAME'].present? && ENV['SENDGRID_WEBHOOK_PASSWORD'].present?) + resources :sendgrid_events, only: :create, format: false, defaults: { format: :json } + end + + ################################################################################ + # Admin routes + + constraints({ host: Gemcutter::SEPARATE_ADMIN_HOST }.compact) do + namespace :admin, constraints: { format: :html }, defaults: { format: 'html' } do + delete 'logout' => 'admin#logout', as: :logout + end + + constraints(Constraints::Admin) do + namespace :admin, constraints: Constraints::Admin::RubygemsOrgAdmin do + mount GoodJob::Engine, at: 'good_job' + mount MaintenanceTasks::Engine, at: "maintenance_tasks" + mount PgHero::Engine, at: "pghero" + end + + mount Avo::Engine, at: Avo.configuration.root_path + end + end + + scope :oauth, constraints: { format: :html }, defaults: { format: 'html' } do + get ':provider/callback', to: 'oauth#create' + get 'failure', to: 'oauth#failure' + + get 'development_log_in_as/:admin_github_user_id', to: 'oauth#development_log_in_as' if Gemcutter::ENABLE_DEVELOPMENT_ADMIN_LOG_IN + end + + ################################################################################ + # Development routes + + if Rails.env.development? + mount LetterOpenerWeb::Engine, at: "/letter_opener" + mount Lookbook::Engine, at: "/lookbook" + end end diff --git a/config/rubygems.yml b/config/rubygems.yml index aa2c9d90e43..96a4b2217bf 100644 --- a/config/rubygems.yml +++ b/config/rubygems.yml @@ -2,6 +2,8 @@ development: protocol: http host: localhost s3_bucket: s3bucket + s3_contents_bucket: contents + s3_compact_index_bucket: compact-index s3_region: us-east-1 s3_endpoint: s3.amazonaws.com versions_file_location: "./config/versions.list" @@ -10,6 +12,8 @@ test: protocol: http host: localhost s3_bucket: test.s3.rubygems.org + s3_contents_bucket: contents.test.s3.rubygems.org + s3_compact_index_bucket: compact-index.test.s3.rubygems.org s3_region: us-east-1 s3_endpoint: s3.amazonaws.com versions_file_location: "./test/helpers/versions.list" @@ -20,6 +24,8 @@ staging: s3_bucket: oregon.staging.s3.rubygems.org s3_region: us-west-2 s3_endpoint: s3-us-west-2.amazonaws.com + s3_contents_bucket: contents.oregon.staging.s3.rubygems.org + s3_compact_index_bucket: compact-index.oregon.staging.s3.rubygems.org versions_file_location: "./config/versions.list" production: @@ -28,4 +34,17 @@ production: s3_bucket: oregon.production.s3.rubygems.org s3_region: us-west-2 s3_endpoint: s3-us-west-2.amazonaws.com + s3_contents_bucket: contents.oregon.production.s3.rubygems.org + s3_compact_index_bucket: compact-index.oregon.production.s3.rubygems.org + versions_file_location: "./config/versions.list" + separate_admin_host: rubygems.team + +oidc-api-token: + protocol: https + host: oidc-api-token.rubygems.org + s3_bucket: oregon.oidc-api-token.s3.rubygems.org + s3_region: us-west-2 + s3_endpoint: s3-us-west-2.amazonaws.com + s3_contents_bucket: contents.oregon.oidc-api-token.s3.rubygems.org + s3_compact_index_bucket: compact-index.oregon.oidc-api-token.s3.rubygems.org versions_file_location: "./config/versions.list" diff --git a/config/secrets.yml b/config/secrets.yml deleted file mode 100644 index f24c947bb1f..00000000000 --- a/config/secrets.yml +++ /dev/null @@ -1,35 +0,0 @@ -# Be sure to restart your server when you modify this file. - -# Your secret key is used for verifying the integrity of signed cookies. -# If you change this key, all old signed cookies will become invalid! - -# Make sure the secret is at least 30 characters and all random, -# no regular words or you'll be exposed to dictionary attacks. -# You can use `rails secret` to generate a secure secret key. - -# Make sure the secrets in this file are kept private -# if you're sharing your code publicly. - -# Dummy sendgrid configuration used for development. -development: - secret_key_base: 01ade4a4dc594f4e2f1711f225adc0ad38b1f4e0b965191a43eea8a658a97d8d5f7a1255791c491f14ca638d4bbc7d82d8990040e266e3d898670605f2e5676f - sendgrid_webhook_username: development_sendgrid_webhook_user - sendgrid_webhook_password: 279a2b980eedbfb71132d73e0ad63989b420ebf3c37f159749f58f2003737734ddd409278157ab171182b9a5bf6d4b4215b6a5535a4b6c2829e88ff14ce74644 - -# Dummy sendgrid configuration used for testing. -test: - secret_key_base: 482e75fe0b819896e400fa4be69a0535382e73a98f147d9f898d6bf2d2d705c85834a91b765b0a4ba018493c38ebaf355acae8ca1f9e654e9c52c6fa969042ac - sendgrid_webhook_username: test_sendgrid_webhook_user - sendgrid_webhook_password: ecf2a26928de3dbb5b90f38216fc3453f3739774fc4f24e61434e9608d355748beac3d70d49a455ff59e17133cdbd316417da2c7d8f11bdd7cd07f85c4fb321j - -# Do not keep production secrets in the repository, -# instead read values from the environment. -staging: - secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> - sendgrid_webhook_username: <%= ENV["SENDGRID_WEBHOOK_USERNAME"] %> - sendgrid_webhook_password: <%= ENV["SENDGRID_WEBHOOK_PASSWORD"] %> - -production: - secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> - sendgrid_webhook_username: <%= ENV["SENDGRID_WEBHOOK_USERNAME"] %> - sendgrid_webhook_password: <%= ENV["SENDGRID_WEBHOOK_PASSWORD"] %> diff --git a/config/tailwind.config.js b/config/tailwind.config.js new file mode 100644 index 00000000000..664dbdb8c31 --- /dev/null +++ b/config/tailwind.config.js @@ -0,0 +1,127 @@ +const defaultTheme = require("tailwindcss/defaultTheme"); + +/** @type {import('tailwindcss').Config} */ +module.exports = { + mode: "jit", + content: [ + "./app/views/**/*.rb", + "./app/components/**/*rb", + "./public/*.html", + "./app/helpers/**/*.rb", + "./app/javascript/**/*.js", + "./app/views/**/*.{erb,haml,html,slim,rb}", + ], + theme: { + extend: { + fontFamily: { + sans: ['"Titillium Web"', ...defaultTheme.fontFamily.sans], + mono: ['"Fira Code"', ...defaultTheme.fontFamily.mono], + }, + fontSize: { + lg: ['1.4375rem', '2.156rem'], /* Base/01 23px, 34.5px */ + base: ['1.1875rem', '1.781rem'], /* Base/02 19px, 28.5px */ + sm: ['1.0000rem', '1.500rem'], /* Base/03 16px, 24px */ + xs: ['0.8750rem', '1.313rem'], /* Base/04 14px, 21px */ + + d1: ['3.1875rem', '3.825rem'], /* Display/01 51px, 61.2px */ + d2: ['2.9375rem', '3.525rem'], /* Display/02 47px, 56.4px */ + d3: ['2.6875rem', '3.225rem'], /* Display/03 43px, 51.6px */ + h1: ['2.4375rem', '2.925rem'], /* Header/01 39px, 46.8px */ + h2: ['2.1875rem', '2.625rem'], /* Header/02 35px, 42px */ + h3: ['1.9375rem', '2.325rem'], /* Header/03 31px, 37.2px */ + h4: ['1.6875rem', '2.025rem'], /* Header/04 27px, 32.4px */ + b1: ['1.4375rem', '2.156rem'], /* Base/01 23px, 34.5px */ + b2: ['1.1875rem', '1.781rem'], /* Base/02 19px, 28.5px */ + b3: ['1.0000rem', '1.500rem'], /* Base/03 16px, 24px */ + b4: ['0.8750rem', '1.313rem'], /* Base/04 14px, 21px */ + c1: ['1.6875rem', '2.025rem'], /* Code/01 27px, 32.4px */ + c2: ['1.4375rem', '1.725rem'], /* Code/02 23px, 27.6px */ + c3: ['1.1250rem', '1.350rem'], /* Code/03 18px, 21.6px */ + c4: ['1.0000rem', '1.200rem'], /* Code/04 16px, 19.2px */ + }, + colors: { + transparent: 'transparent', + current: 'currentColor', + 'white': '#ffffff', + 'red': { + 100: '#FFEEF1', + 200: '#FFC4CD', + 300: '#FF9CB0', + 400: '#FF0E3B', + 500: '#E4002B', + 600: '#BA0023', + 700: '#970019', + 800: '#730012', + 900: '#58000A', + }, + 'orange': { + 100: '#FFF0EC', + 200: '#FFD0C5', + 300: '#FFA983', + 400: '#FF7539', + 500: '#F74C27', + 600: '#E54523', + 700: '#AD2F14', + 800: '#761A05', + 900: '#631200', + }, + 'yellow': { + 100: '#FFFBF7', + 200: '#FFF4EA', + 300: '#FFE4BB', + 400: '#FFC772', + 500: '#FFAB2D', + 600: '#D38C22', + 700: '#A66D17', + 800: '#7A4E0C', + 900: '#4D2E00', + }, + 'green': { + 100: '#F1FFFE', + 200: '#E1FFFC', + 300: '#C9FFF9', + 400: '#06B8B9', + 500: '#05A3A7', + 600: '#03858B', + 700: '#006770', + 800: '#004F56', + 900: '#00373B', + }, + 'blue': { + 100: '#F3F9FF', + 200: '#E1F1FF', + 300: '#92C0F4', + 400: '#76ADEC', + 500: '#6999D2', + 600: '#5B86B8', + 700: '#3F699A', + 800: '#234C7D', + 900: '#113765', + }, + 'neutral': { + '000': '#FFFFFF', + '050': '#FBFBFB', + '100': '#F6F6F6', + '200': '#EEEEEE', + '300': '#E2E2E2', + '400': '#D5D5D5', + '500': '#C3C5C7', + '600': '#969CA7', + '700': '#636B79', + '800': '#454C59', + '850': '#2F3643', + '900': '#13181F', + '950': '#191E26', + }, + }, + }, + }, + plugins: [ + require("@tailwindcss/forms"), + require("@tailwindcss/aspect-ratio"), + require("@tailwindcss/typography"), + require("@tailwindcss/container-queries"), + ], + corePlugins: { + }, +}; diff --git a/config/unicorn.conf b/config/unicorn.conf deleted file mode 100644 index ee5c809b969..00000000000 --- a/config/unicorn.conf +++ /dev/null @@ -1,13 +0,0 @@ -listen 3000 - -working_directory '/app' - -timeout 60 - -preload_app false - -worker_processes 8 - -before_fork do |server, worker| - sleep 1 -end diff --git a/db/downloads_schema.rb b/db/downloads_schema.rb new file mode 100644 index 00000000000..c390619acd2 --- /dev/null +++ b/db/downloads_schema.rb @@ -0,0 +1,18 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[7.1].define(version: 0) do + # These are extensions that must be enabled in order to support this database + enable_extension "plpgsql" + enable_extension "timescaledb" + +end diff --git a/db/migrate/20090527120326_clearance_create_users.rb b/db/migrate/20090527120326_clearance_create_users.rb index 2380b22c482..9cd48219e35 100644 --- a/db/migrate/20090527120326_clearance_create_users.rb +++ b/db/migrate/20090527120326_clearance_create_users.rb @@ -1,6 +1,6 @@ class ClearanceCreateUsers < ActiveRecord::Migration[4.2] def self.up - create_table(:users) do |t| + create_table(:users) do |t| # rubocop:disable Rails/CreateTableWithTimestamps t.string :email t.string :encrypted_password, limit: 128 t.string :salt, limit: 128 @@ -9,7 +9,7 @@ def self.up t.boolean :email_confirmed, default: false, null: false end - add_index :users, [:id, :token] + add_index :users, %i[id token] add_index :users, :email add_index :users, :token end diff --git a/db/migrate/20090610115456_add_requirements.rb b/db/migrate/20090610115456_add_requirements.rb index 9fa2b44602e..6eaebe20f1d 100644 --- a/db/migrate/20090610115456_add_requirements.rb +++ b/db/migrate/20090610115456_add_requirements.rb @@ -1,6 +1,6 @@ class AddRequirements < ActiveRecord::Migration[4.2] def self.up - create_table :requirements do |t| + create_table :requirements do |t| # rubocop:disable Rails/CreateTableWithTimestamps t.integer "version_id" t.integer "dependency_id" end diff --git a/db/migrate/20090612020811_add_missing_indicies.rb b/db/migrate/20090612020811_add_missing_indicies.rb index 44c514f8157..c469a8e42b7 100644 --- a/db/migrate/20090612020811_add_missing_indicies.rb +++ b/db/migrate/20090612020811_add_missing_indicies.rb @@ -1,19 +1,19 @@ class AddMissingIndicies < ActiveRecord::Migration[4.2] def self.up - add_index 'rubygems', 'name' - add_index 'linksets', 'rubygem_id' - add_index 'versions', 'rubygem_id' - add_index 'requirements', 'version_id' - add_index 'requirements', 'dependency_id' - add_index 'dependencies', 'rubygem_id' + add_index "rubygems", "name" + add_index "linksets", "rubygem_id" + add_index "versions", "rubygem_id" + add_index "requirements", "version_id" + add_index "requirements", "dependency_id" + add_index "dependencies", "rubygem_id" end def self.down - remove_index 'rubygems', 'name' - remove_index 'linksets', 'rubygem_id' - remove_index 'versions', 'rubygem_id' - remove_index 'requirements', 'version_id' - remove_index 'requirements', 'dependency_id' - remove_index 'dependencies', 'rubygem_id' + remove_index "rubygems", "name" + remove_index "linksets", "rubygem_id" + remove_index "versions", "rubygem_id" + remove_index "requirements", "version_id" + remove_index "requirements", "dependency_id" + remove_index "dependencies", "rubygem_id" end end diff --git a/db/migrate/20090808034224_create_delayed_jobs.rb b/db/migrate/20090808034224_create_delayed_jobs.rb index 4c9bd2edfd3..6439641100b 100644 --- a/db/migrate/20090808034224_create_delayed_jobs.rb +++ b/db/migrate/20090808034224_create_delayed_jobs.rb @@ -8,7 +8,7 @@ def self.up table.datetime :run_at # When to run. Could be Time.now for immediately, or sometime in the future. table.datetime :locked_at # Set when a client is working on this object table.datetime :failed_at # Set when all retries have failed (actually, by default, the record is deleted instead) - table.string :locked_by # Who is working on this object (if locked) + table.string :locked_by # Who is working on this object (if locked) table.timestamps end end diff --git a/db/migrate/20090820185410_drop_requirements_table.rb b/db/migrate/20090820185410_drop_requirements_table.rb index 09590c6a1dc..cead215e231 100644 --- a/db/migrate/20090820185410_drop_requirements_table.rb +++ b/db/migrate/20090820185410_drop_requirements_table.rb @@ -21,7 +21,7 @@ def self.up end def self.down - create_table :requirements do |t| + create_table :requirements do |t| # rubocop:disable Rails/CreateTableWithTimestamps t.integer :version_id t.integer :dependency_id end diff --git a/db/migrate/20090821044418_add_scope_to_dependencies.rb b/db/migrate/20090821044418_add_scope_to_dependencies.rb index 09c37e0ffe2..7ec42fbbd66 100644 --- a/db/migrate/20090821044418_add_scope_to_dependencies.rb +++ b/db/migrate/20090821044418_add_scope_to_dependencies.rb @@ -1,8 +1,8 @@ class AddScopeToDependencies < ActiveRecord::Migration[4.2] def self.up add_column :dependencies, :scope, :string - Dependency.update_all(scope: 'runtime') - announce "Please reprocess all gems after this migration" + # Dependency.update_all(scope: "runtime") + # announce "Please reprocess all gems after this migration" end def self.down diff --git a/db/migrate/20090826035230_fix_version_dates.rb b/db/migrate/20090826035230_fix_version_dates.rb index 0585c28c962..161091273a3 100644 --- a/db/migrate/20090826035230_fix_version_dates.rb +++ b/db/migrate/20090826035230_fix_version_dates.rb @@ -2,7 +2,7 @@ class FixVersionDates < ActiveRecord::Migration[4.2] def self.up rename_column :versions, :created_at, :built_at add_column :versions, :created_at, :datetime - Version.update_all('created_at = updated_at') + # Version.update_all("created_at = updated_at") end def self.down diff --git a/db/migrate/20090901141418_add_more_missing_indexes.rb b/db/migrate/20090901141418_add_more_missing_indexes.rb index 560c331af56..416bac847d4 100644 --- a/db/migrate/20090901141418_add_more_missing_indexes.rb +++ b/db/migrate/20090901141418_add_more_missing_indexes.rb @@ -1,13 +1,13 @@ class AddMoreMissingIndexes < ActiveRecord::Migration[4.2] def self.up - add_index 'subscriptions', 'rubygem_id' - add_index 'subscriptions', 'user_id' - add_index 'dependencies', 'version_id' + add_index "subscriptions", "rubygem_id" + add_index "subscriptions", "user_id" + add_index "dependencies", "version_id" end def self.down - remove_index 'subscriptions', 'rubygem_id' - remove_index 'subscriptions', 'user_id' - remove_index 'dependencies', 'version_id' + remove_index "subscriptions", "rubygem_id" + remove_index "subscriptions", "user_id" + remove_index "dependencies", "version_id" end end diff --git a/db/migrate/20090910143608_clearance_update_users.rb b/db/migrate/20090910143608_clearance_update_users.rb index 4bdfce5b2fe..e9b261e75de 100644 --- a/db/migrate/20090910143608_clearance_update_users.rb +++ b/db/migrate/20090910143608_clearance_update_users.rb @@ -1,16 +1,16 @@ class ClearanceUpdateUsers < ActiveRecord::Migration[4.2] def self.up - change_table(:users) do |t| + change_table(:users, bulk: true) do |t| t.string :confirmation_token, limit: 128 t.string :remember_token, limit: 128 end - add_index :users, [:id, :confirmation_token] + add_index :users, %i[id confirmation_token] add_index :users, :remember_token end def self.down - change_table(:users) do |t| + change_table(:users, bulk: true) do |t| t.remove :confirmation_token, :remember_token end end diff --git a/db/migrate/20090917035818_add_prerelease_to_versions.rb b/db/migrate/20090917035818_add_prerelease_to_versions.rb index 16f14fb8975..fe2b37da6f3 100644 --- a/db/migrate/20090917035818_add_prerelease_to_versions.rb +++ b/db/migrate/20090917035818_add_prerelease_to_versions.rb @@ -1,7 +1,7 @@ class AddPrereleaseToVersions < ActiveRecord::Migration[4.2] def self.up add_column :versions, :prerelease, :boolean - Version.update_all(prerelease: false) + # Version.update_all(prerelease: false) end def self.down diff --git a/db/migrate/20090930181320_add_position_to_versions.rb b/db/migrate/20090930181320_add_position_to_versions.rb index 11a32f99c6c..0afe8fc3a2a 100644 --- a/db/migrate/20090930181320_add_position_to_versions.rb +++ b/db/migrate/20090930181320_add_position_to_versions.rb @@ -2,7 +2,7 @@ class AddPositionToVersions < ActiveRecord::Migration[4.2] def self.up add_column :versions, :position, :integer - Rubygem.all.each(&:reorder_versions) + # Rubygem.find_each(&:reorder_versions) end def self.down diff --git a/db/migrate/20091009213456_reorder_versions_again.rb b/db/migrate/20091009213456_reorder_versions_again.rb index 7e9a00f5b75..44e9b53a90d 100644 --- a/db/migrate/20091009213456_reorder_versions_again.rb +++ b/db/migrate/20091009213456_reorder_versions_again.rb @@ -1,6 +1,6 @@ class ReorderVersionsAgain < ActiveRecord::Migration[4.2] def self.up - Rubygem.all.each(&:reorder_versions) + # Rubygem.find_each(&:reorder_versions) end def self.down diff --git a/db/migrate/20091021203534_add_latest_to_version.rb b/db/migrate/20091021203534_add_latest_to_version.rb index bd4f844fae6..3320f199ee0 100644 --- a/db/migrate/20091021203534_add_latest_to_version.rb +++ b/db/migrate/20091021203534_add_latest_to_version.rb @@ -2,7 +2,7 @@ class AddLatestToVersion < ActiveRecord::Migration[4.2] def self.up add_column :versions, :latest, :boolean - Rubygem.all.each(&:reorder_versions) + # Rubygem.find_each(&:reorder_versions) end def self.down diff --git a/db/migrate/20091026124711_create_rubyforgers.rb b/db/migrate/20091026124711_create_rubyforgers.rb index ad54a094380..562ab28e62c 100644 --- a/db/migrate/20091026124711_create_rubyforgers.rb +++ b/db/migrate/20091026124711_create_rubyforgers.rb @@ -1,6 +1,6 @@ class CreateRubyforgers < ActiveRecord::Migration[4.2] def self.up - create_table :rubyforgers do |t| + create_table :rubyforgers do |t| # rubocop:disable Rails/CreateTableWithTimestamps t.string :email t.string :encrypted_password, limit: 40 end diff --git a/db/migrate/20091026234707_fix_dependencies.rb b/db/migrate/20091026234707_fix_dependencies.rb index ce0a46409f2..fcf1a23a5c2 100644 --- a/db/migrate/20091026234707_fix_dependencies.rb +++ b/db/migrate/20091026234707_fix_dependencies.rb @@ -1,20 +1,20 @@ class FixDependencies < ActiveRecord::Migration[4.2] def self.up - # fix bad version reqs - Dependency.all.each do |dep| - reqs = dep.requirements - begin - Gem::Requirement.new(reqs) - rescue ArgumentError - list = reqs.split(/(>=)|(<=)|(~>)|(>)|(<)|(=)/).reject(&:empty?) - fixed = list[0] + list[1] + ", " + list[2] + list[3] + # # fix bad version reqs + # Dependency.find_each do |dep| + # reqs = dep.requirements + # begin + # Gem::Requirement.new(reqs) + # rescue ArgumentError + # list = reqs.split(/(>=)|(<=)|(~>)|(>)|(<)|(=)/).reject(&:empty?) + # fixed = "#{list[0]}#{list[1]}, #{list[2]}#{list[3]}" - dep.update_attribute(:requirements, fixed) - end - end + # dep.update_attribute(:requirements, fixed) + # end + # end - # kill bad deps too - Dependency.includes(:rubygem).where(rubygems: { id: nil }).destroy_all + # # kill bad deps too + # Dependency.includes(:rubygem).where(rubygems: { id: nil }).destroy_all end def self.down diff --git a/db/migrate/20091109203935_add_full_name_to_versions.rb b/db/migrate/20091109203935_add_full_name_to_versions.rb index 729fcce2af1..e9165bd004c 100644 --- a/db/migrate/20091109203935_add_full_name_to_versions.rb +++ b/db/migrate/20091109203935_add_full_name_to_versions.rb @@ -1,11 +1,11 @@ class AddFullNameToVersions < ActiveRecord::Migration[4.2] def self.up add_column :versions, :full_name, :string - add_index 'versions', 'full_name' + add_index "versions", "full_name" end def self.down - remove_index 'versions', 'full_name' + remove_index "versions", "full_name" remove_column :versions, :full_name end end diff --git a/db/migrate/20110318162103_add_unique_indexes.rb b/db/migrate/20110318162103_add_unique_indexes.rb index 4b0cdd95cc1..c07df5af9c4 100644 --- a/db/migrate/20110318162103_add_unique_indexes.rb +++ b/db/migrate/20110318162103_add_unique_indexes.rb @@ -2,11 +2,11 @@ class AddUniqueIndexes < ActiveRecord::Migration[4.2] def self.up remove_index :rubygems, column: [:name] add_index :rubygems, [:name], unique: true - add_index :versions, [:rubygem_id, :number, :platform], unique: true + add_index :versions, %i[rubygem_id number platform], unique: true end def self.down - remove_index :versions, column: [:rubygem_id, :number, :platform] + remove_index :versions, column: %i[rubygem_id number platform] remove_index :rubygems, column: [:name] add_index :rubygems, [:name] end diff --git a/db/migrate/20110805014415_remove_improperly_embedded_yaml_data.rb b/db/migrate/20110805014415_remove_improperly_embedded_yaml_data.rb index 63543ddcbde..a7cab1fffa9 100644 --- a/db/migrate/20110805014415_remove_improperly_embedded_yaml_data.rb +++ b/db/migrate/20110805014415_remove_improperly_embedded_yaml_data.rb @@ -1,9 +1,9 @@ class RemoveImproperlyEmbeddedYamlData < ActiveRecord::Migration[4.2] def self.up - Dependency.where("requirements like '%YAML::Syck::DefaultKey%'").each do |d| - d.requirements = d.clean_requirements - d.save(validate: false) - end + # Dependency.where("requirements like '%YAML::Syck::DefaultKey%'").find_each do |d| + # d.requirements = d.clean_requirements + # d.save(validate: false) + # end end def self.down diff --git a/db/migrate/20120915212528_add_version_history_indexes.rb b/db/migrate/20120915212528_add_version_history_indexes.rb index 9c875b285a5..2ebb3788465 100644 --- a/db/migrate/20120915212528_add_version_history_indexes.rb +++ b/db/migrate/20120915212528_add_version_history_indexes.rb @@ -1,9 +1,9 @@ class AddVersionHistoryIndexes < ActiveRecord::Migration[4.2] def up - add_index :version_histories, [:version_id, :day], unique: true + add_index :version_histories, %i[version_id day], unique: true end def down - remove_index :version_histories, column: [:version_id, :day] + remove_index :version_histories, column: %i[version_id day] end end diff --git a/db/migrate/20120916165331_remove_timestamps_on_version_history.rb b/db/migrate/20120916165331_remove_timestamps_on_version_history.rb index 8eff9c66d2c..15b59caabcc 100644 --- a/db/migrate/20120916165331_remove_timestamps_on_version_history.rb +++ b/db/migrate/20120916165331_remove_timestamps_on_version_history.rb @@ -1,7 +1,9 @@ class RemoveTimestampsOnVersionHistory < ActiveRecord::Migration[4.2] def up - remove_column :version_histories, :created_at - remove_column :version_histories, :updated_at + change_table(:version_histories, bulk: true) do |t| + t.remove :created_at + t.remove :updated_at + end end def down diff --git a/db/migrate/20141009120000_add_rubygems_name_index.rb b/db/migrate/20141009120000_add_rubygems_name_index.rb index 2f0c09b7233..c56a3477f9e 100644 --- a/db/migrate/20141009120000_add_rubygems_name_index.rb +++ b/db/migrate/20141009120000_add_rubygems_name_index.rb @@ -5,6 +5,6 @@ def up end def down - remove_index :rubygems, :name => :rubygems_name_upcase + remove_index :rubygems, name: :rubygems_name_upcase end end diff --git a/db/migrate/20150124074536_add_metadata_to_versions.rb b/db/migrate/20150124074536_add_metadata_to_versions.rb index a6d17fbf9bd..feb0937bfaf 100644 --- a/db/migrate/20150124074536_add_metadata_to_versions.rb +++ b/db/migrate/20150124074536_add_metadata_to_versions.rb @@ -1,6 +1,6 @@ class AddMetadataToVersions < ActiveRecord::Migration[4.2] def change - enable_extension 'hstore' + enable_extension "hstore" add_column :versions, :metadata, :hstore, default: {}, null: false end diff --git a/db/migrate/20150209074817_remove_gittip.rb b/db/migrate/20150209074817_remove_gittip.rb index a7aadc66294..dadd09d6e2f 100644 --- a/db/migrate/20150209074817_remove_gittip.rb +++ b/db/migrate/20150209074817_remove_gittip.rb @@ -1,5 +1,5 @@ class RemoveGittip < ActiveRecord::Migration[4.2] def change - remove_column :users, :gittip_username + remove_column :users, :gittip_username # rubocop:disable Rails/ReversibleMigration end end diff --git a/db/migrate/20150709170542_create_doorkeeper_tables.rb b/db/migrate/20150709170542_create_doorkeeper_tables.rb index 37ecbe7b428..99622ef44a4 100644 --- a/db/migrate/20150709170542_create_doorkeeper_tables.rb +++ b/db/migrate/20150709170542_create_doorkeeper_tables.rb @@ -5,7 +5,7 @@ def change t.string :uid, null: false t.string :secret, null: false t.text :redirect_uri, null: false - t.string :scopes, null: false, default: '' + t.string :scopes, null: false, default: "" t.timestamps end @@ -27,11 +27,11 @@ def change create_table :oauth_access_tokens do |t| t.integer :resource_owner_id t.integer :application_id - t.string :token, null: false + t.string :token, null: false t.string :refresh_token t.integer :expires_in t.datetime :revoked_at - t.datetime :created_at, null: false + t.datetime :created_at, null: false t.string :scopes end diff --git a/db/migrate/20160227194735_create_log_tickets.rb b/db/migrate/20160227194735_create_log_tickets.rb index c7b666a9567..2f08bc08b52 100644 --- a/db/migrate/20160227194735_create_log_tickets.rb +++ b/db/migrate/20160227194735_create_log_tickets.rb @@ -9,6 +9,6 @@ def change t.timestamps null: false end - add_index :log_tickets, [:directory, :key], unique: true + add_index :log_tickets, %i[directory key], unique: true end end diff --git a/db/migrate/20160318213755_create_gem_download.rb b/db/migrate/20160318213755_create_gem_download.rb index d7b4602a9c4..754f32d9ba5 100644 --- a/db/migrate/20160318213755_create_gem_download.rb +++ b/db/migrate/20160318213755_create_gem_download.rb @@ -1,10 +1,10 @@ class CreateGemDownload < ActiveRecord::Migration[4.2] def change - create_table :gem_downloads do |t| + create_table :gem_downloads do |t| # rubocop:disable Rails/CreateTableWithTimestamps t.integer :rubygem_id, null: false t.integer :version_id, null: false t.column :count, :bigint end - add_index :gem_downloads, [:rubygem_id, :version_id], unique: true + add_index :gem_downloads, %i[rubygem_id version_id], unique: true end end diff --git a/db/migrate/20160329184508_create_gem_download_count_index.rb b/db/migrate/20160329184508_create_gem_download_count_index.rb index ace5f924bfb..31711a5c8c5 100644 --- a/db/migrate/20160329184508_create_gem_download_count_index.rb +++ b/db/migrate/20160329184508_create_gem_download_count_index.rb @@ -1,5 +1,5 @@ class CreateGemDownloadCountIndex < ActiveRecord::Migration[4.2] def change - add_index :gem_downloads, [:version_id, :rubygem_id, :count] + add_index :gem_downloads, %i[version_id rubygem_id count] end end diff --git a/db/migrate/20160516141824_remove_version_history.rb b/db/migrate/20160516141824_remove_version_history.rb index b4b6cf1af35..15408dd3d0a 100644 --- a/db/migrate/20160516141824_remove_version_history.rb +++ b/db/migrate/20160516141824_remove_version_history.rb @@ -4,12 +4,12 @@ def up end def down - create_table :version_histories do |t| + create_table :version_histories do |t| # rubocop:disable Rails/CreateTableWithTimestamps t.integer :version_id t.date :day t.integer :count end - add_index :version_histories, [:version_id, :day], unique: true + add_index :version_histories, %i[version_id day], unique: true end end diff --git a/db/migrate/20160516144704_remove_rubyforger.rb b/db/migrate/20160516144704_remove_rubyforger.rb index 40750c9fbd7..152343eae3b 100644 --- a/db/migrate/20160516144704_remove_rubyforger.rb +++ b/db/migrate/20160516144704_remove_rubyforger.rb @@ -5,7 +5,7 @@ def up end def down - create_table :rubyforgers do |t| + create_table :rubyforgers do |t| # rubocop:disable Rails/CreateTableWithTimestamps t.string :email t.string :encrypted_password, limit: 40 end diff --git a/db/migrate/20160527171228_add_required_rubygems_version.rb b/db/migrate/20160527171228_add_required_rubygems_version.rb index 970fd42f5a9..9bcc888cc8f 100644 --- a/db/migrate/20160527171228_add_required_rubygems_version.rb +++ b/db/migrate/20160527171228_add_required_rubygems_version.rb @@ -1,6 +1,15 @@ class AddRequiredRubygemsVersion < ActiveRecord::Migration[4.2] - def change - remove_column :versions, :rubygems_version, :string - add_column :versions, :required_rubygems_version, :string + def up + change_table(:versions, bulk: true) do |t| + t.remove :rubygems_version + t.string :required_rubygems_version + end + end + + def down + change_table(:versions, bulk: true) do |t| + t.remove :required_rubygems_version + t.string :rubygems_version + end end end diff --git a/db/migrate/20160929104437_remove_doorkeeper_tables.rb b/db/migrate/20160929104437_remove_doorkeeper_tables.rb index b89f0c62be1..f1e1132fb5f 100644 --- a/db/migrate/20160929104437_remove_doorkeeper_tables.rb +++ b/db/migrate/20160929104437_remove_doorkeeper_tables.rb @@ -11,7 +11,7 @@ def down t.string :uid, null: false t.string :secret, null: false t.text :redirect_uri, null: false - t.string :scopes, null: false, default: '' + t.string :scopes, null: false, default: "" t.timestamps end @@ -33,11 +33,11 @@ def down create_table :oauth_access_tokens do |t| t.integer :resource_owner_id t.integer :application_id - t.string :token, null: false + t.string :token, null: false t.string :refresh_token t.integer :expires_in t.datetime :revoked_at - t.datetime :created_at, null: false + t.datetime :created_at, null: false t.string :scopes end diff --git a/db/migrate/20180525160703_add_mfa_to_users.rb b/db/migrate/20180525160703_add_mfa_to_users.rb index d1f80cf77b4..eb9ac32b845 100644 --- a/db/migrate/20180525160703_add_mfa_to_users.rb +++ b/db/migrate/20180525160703_add_mfa_to_users.rb @@ -1,8 +1,10 @@ class AddMfaToUsers < ActiveRecord::Migration[5.0] def change - add_column :users, :mfa_seed, :string - add_column :users, :mfa_level, :integer, default: 0 - add_column :users, :mfa_recovery_codes, :string, array: true, default: [] - add_column :users, :last_otp_at, :datetime + change_table(:users, bulk: true) do |t| + t.string :mfa_seed + t.integer :mfa_level, default: 0 + t.string :mfa_recovery_codes, array: true, default: [] + t.datetime :last_otp_at + end end end diff --git a/db/migrate/20181020173922_remove_users_last_otp_at.rb b/db/migrate/20181020173922_remove_users_last_otp_at.rb index 774f04db807..a2d1dfd13bd 100644 --- a/db/migrate/20181020173922_remove_users_last_otp_at.rb +++ b/db/migrate/20181020173922_remove_users_last_otp_at.rb @@ -1,5 +1,5 @@ class RemoveUsersLastOtpAt < ActiveRecord::Migration[5.1] def change - remove_column :users, :last_otp_at + remove_column :users, :last_otp_at # rubocop:disable Rails/ReversibleMigration end end diff --git a/db/migrate/20181022172318_add_index_to_web_hooks.rb b/db/migrate/20181022172318_add_index_to_web_hooks.rb index 7b9560cd5f0..98fe99c2f2d 100644 --- a/db/migrate/20181022172318_add_index_to_web_hooks.rb +++ b/db/migrate/20181022172318_add_index_to_web_hooks.rb @@ -1,5 +1,5 @@ class AddIndexToWebHooks < ActiveRecord::Migration[5.2] def change - add_index :web_hooks, [:user_id, :rubygem_id] + add_index :web_hooks, %i[user_id rubygem_id] end end diff --git a/db/migrate/20181128191130_add_index_to_lowercase_email.rb b/db/migrate/20181128191130_add_index_to_lowercase_email.rb new file mode 100644 index 00000000000..c43036cb05f --- /dev/null +++ b/db/migrate/20181128191130_add_index_to_lowercase_email.rb @@ -0,0 +1,9 @@ +class AddIndexToLowercaseEmail < ActiveRecord::Migration[5.2] + def up + add_index "users", "lower(email) varchar_pattern_ops", name: "index_users_on_lower_email" + end + + def down + remove_index "users", name: "index_users_on_lower_email" + end +end diff --git a/db/migrate/20190220164205_add_lower_full_name_index_to_versions.rb b/db/migrate/20190220164205_add_lower_full_name_index_to_versions.rb index a4c1c7d4917..165fa872cf5 100644 --- a/db/migrate/20190220164205_add_lower_full_name_index_to_versions.rb +++ b/db/migrate/20190220164205_add_lower_full_name_index_to_versions.rb @@ -1,5 +1,5 @@ class AddLowerFullNameIndexToVersions < ActiveRecord::Migration[5.2] def change - add_index :versions, 'lower(full_name)' + add_index :versions, "lower(full_name)" end end diff --git a/db/migrate/20200502214958_add_limit_to_required_rubygems_version.rb b/db/migrate/20200502214958_add_limit_to_required_rubygems_version.rb index 68bd665fdb3..157bb0d132c 100644 --- a/db/migrate/20200502214958_add_limit_to_required_rubygems_version.rb +++ b/db/migrate/20200502214958_add_limit_to_required_rubygems_version.rb @@ -1,5 +1,5 @@ class AddLimitToRequiredRubygemsVersion < ActiveRecord::Migration[6.0] def change - change_column :versions, :required_rubygems_version, :string, limit: Gemcutter::MAX_FIELD_LENGTH + change_column :versions, :required_rubygems_version, :string, limit: Gemcutter::MAX_FIELD_LENGTH # rubocop:disable Rails/ReversibleMigration end end diff --git a/db/migrate/20200508234114_add_indexed_to_rubygems.rb b/db/migrate/20200508234114_add_indexed_to_rubygems.rb index a5e1d890ce3..776c5537808 100644 --- a/db/migrate/20200508234114_add_indexed_to_rubygems.rb +++ b/db/migrate/20200508234114_add_indexed_to_rubygems.rb @@ -3,8 +3,8 @@ def change add_column :rubygems, :indexed, :boolean, null: false, default: false add_index :rubygems, :indexed - say_with_time "populating indexed rubygems table column" do - Rubygem.joins(:versions).where(versions: {indexed: true}).update_all(indexed: true) - end + # say_with_time "populating indexed rubygems table column" do + # Rubygem.joins(:versions).where(versions: { indexed: true }).update_all(indexed: true) + # end end end diff --git a/db/migrate/20200516071636_add_ownership_confirmation.rb b/db/migrate/20200516071636_add_ownership_confirmation.rb index 308cdf39fda..6a4bb896c62 100644 --- a/db/migrate/20200516071636_add_ownership_confirmation.rb +++ b/db/migrate/20200516071636_add_ownership_confirmation.rb @@ -1,9 +1,11 @@ class AddOwnershipConfirmation < ActiveRecord::Migration[6.0] def change - add_column :ownerships, :confirmed_at, :datetime - add_column :ownerships, :token_expires_at, :datetime - add_column :ownerships, :owner_notifier, :boolean, default: true, null: false - add_column :ownerships, :authorizer_id, :integer - add_index :ownerships, [:user_id, :rubygem_id], unique: true + change_table(:ownerships, bulk: true) do |t| + t.datetime :confirmed_at + t.datetime :token_expires_at + t.boolean :owner_notifier, default: true, null: false + t.integer :authorizer_id + t.index %i[user_id rubygem_id], unique: true + end end end diff --git a/db/migrate/20200708064226_add_cert_chain_to_versions.rb b/db/migrate/20200708064226_add_cert_chain_to_versions.rb new file mode 100644 index 00000000000..73adf54e621 --- /dev/null +++ b/db/migrate/20200708064226_add_cert_chain_to_versions.rb @@ -0,0 +1,5 @@ +class AddCertChainToVersions < ActiveRecord::Migration[6.0] + def change + add_column :versions, :cert_chain, :text + end +end diff --git a/db/migrate/20200721153502_create_ownership_transfer.rb b/db/migrate/20200721153502_create_ownership_transfer.rb new file mode 100644 index 00000000000..bce15d132e4 --- /dev/null +++ b/db/migrate/20200721153502_create_ownership_transfer.rb @@ -0,0 +1,21 @@ +class CreateOwnershipTransfer < ActiveRecord::Migration[6.0] + def change + create_table :ownership_calls do |t| + t.belongs_to :rubygem + t.belongs_to :user + t.text :note + t.boolean :status, default: true, null: false + t.timestamps + end + + create_table :ownership_requests do |t| + t.belongs_to :rubygem + t.belongs_to :ownership_call + t.belongs_to :user + t.text :note + t.integer :status, limit: 1, default: 0, null: false + t.integer :approver_id + t.timestamps + end + end +end diff --git a/db/migrate/20210124062231_add_index_to_versions_indexed_yanked_at.rb b/db/migrate/20210124062231_add_index_to_versions_indexed_yanked_at.rb index cfdf1c809cc..5adfad1bb50 100644 --- a/db/migrate/20210124062231_add_index_to_versions_indexed_yanked_at.rb +++ b/db/migrate/20210124062231_add_index_to_versions_indexed_yanked_at.rb @@ -1,5 +1,5 @@ class AddIndexToVersionsIndexedYankedAt < ActiveRecord::Migration[6.1] def change - add_index :versions, [:indexed, :yanked_at] + add_index :versions, %i[indexed yanked_at] end end diff --git a/db/migrate/20210125153619_add_index_to_gem_downloads_count_desc.rb b/db/migrate/20210125153619_add_index_to_gem_downloads_count_desc.rb index 6f3e33a6a7c..6797723cf22 100644 --- a/db/migrate/20210125153619_add_index_to_gem_downloads_count_desc.rb +++ b/db/migrate/20210125153619_add_index_to_gem_downloads_count_desc.rb @@ -1,5 +1,5 @@ class AddIndexToGemDownloadsCountDesc < ActiveRecord::Migration[6.1] def change - add_index :gem_downloads, [:count], order: {count: :desc} + add_index :gem_downloads, [:count], order: { count: :desc } end end diff --git a/db/migrate/20210307101812_change_users_hide_email_default.rb b/db/migrate/20210307101812_change_users_hide_email_default.rb new file mode 100644 index 00000000000..06652ac02b8 --- /dev/null +++ b/db/migrate/20210307101812_change_users_hide_email_default.rb @@ -0,0 +1,5 @@ +class ChangeUsersHideEmailDefault < ActiveRecord::Migration[6.1] + def change + change_column_default :users, :hide_email, from: nil, to: true + end +end diff --git a/db/migrate/20211009134738_add_blocked_email_to_users.rb b/db/migrate/20211009134738_add_blocked_email_to_users.rb new file mode 100644 index 00000000000..a1ecacb46c1 --- /dev/null +++ b/db/migrate/20211009134738_add_blocked_email_to_users.rb @@ -0,0 +1,5 @@ +class AddBlockedEmailToUsers < ActiveRecord::Migration[6.1] + def change + add_column :users, :blocked_email, :string + end +end diff --git a/db/migrate/20211027170037_add_mfa_column_to_api_keys.rb b/db/migrate/20211027170037_add_mfa_column_to_api_keys.rb new file mode 100644 index 00000000000..96e51e34baf --- /dev/null +++ b/db/migrate/20211027170037_add_mfa_column_to_api_keys.rb @@ -0,0 +1,5 @@ +class AddMfaColumnToApiKeys < ActiveRecord::Migration[6.1] + def change + add_column :api_keys, :mfa, :boolean, default: false, null: false + end +end diff --git a/db/migrate/20211114083036_add_webauth_id_to_users.rb b/db/migrate/20211114083036_add_webauth_id_to_users.rb new file mode 100644 index 00000000000..bdd6a3460cd --- /dev/null +++ b/db/migrate/20211114083036_add_webauth_id_to_users.rb @@ -0,0 +1,5 @@ +class AddWebauthIdToUsers < ActiveRecord::Migration[6.1] + def change + add_column :users, :webauthn_id, :string + end +end diff --git a/db/migrate/20211129081508_create_webauthn_credentials.rb b/db/migrate/20211129081508_create_webauthn_credentials.rb new file mode 100644 index 00000000000..e0220163243 --- /dev/null +++ b/db/migrate/20211129081508_create_webauthn_credentials.rb @@ -0,0 +1,15 @@ +class CreateWebauthnCredentials < ActiveRecord::Migration[6.1] + def change + create_table :webauthn_credentials do |t| + t.references :user, null: false, foreign_key: true + t.string :external_id, null: false + t.string :public_key, null: false + t.string :nickname, null: false + t.bigint :sign_count, default: 0, null: false + + t.timestamps + + t.index :external_id, unique: true + end + end +end diff --git a/db/migrate/20211130050124_add_ownership_request_notifierto_ownerships.rb b/db/migrate/20211130050124_add_ownership_request_notifierto_ownerships.rb new file mode 100644 index 00000000000..e91851a345b --- /dev/null +++ b/db/migrate/20211130050124_add_ownership_request_notifierto_ownerships.rb @@ -0,0 +1,5 @@ +class AddOwnershipRequestNotifiertoOwnerships < ActiveRecord::Migration[6.1] + def change + add_column :ownerships, :ownership_request_notifier, :boolean, default: true, null: false + end +end diff --git a/db/migrate/20220329203703_create_api_key_rubygem_scope.rb b/db/migrate/20220329203703_create_api_key_rubygem_scope.rb new file mode 100644 index 00000000000..41c44ad59af --- /dev/null +++ b/db/migrate/20220329203703_create_api_key_rubygem_scope.rb @@ -0,0 +1,9 @@ +class CreateApiKeyRubygemScope < ActiveRecord::Migration[7.0] + def change + create_table :api_key_rubygem_scopes do |t| + t.references :api_key, null: false + t.references :ownership, null: false, index: false + t.timestamps + end + end +end diff --git a/db/migrate/20220329203956_add_soft_deleted_at_to_api_keys.rb b/db/migrate/20220329203956_add_soft_deleted_at_to_api_keys.rb new file mode 100644 index 00000000000..6d993c433b0 --- /dev/null +++ b/db/migrate/20220329203956_add_soft_deleted_at_to_api_keys.rb @@ -0,0 +1,8 @@ +class AddSoftDeletedAtToApiKeys < ActiveRecord::Migration[7.0] + def change + change_table(:api_keys, bulk: true) do |t| + t.datetime :soft_deleted_at + t.string :soft_deleted_rubygem_name + end + end +end diff --git a/db/migrate/20220505191606_add_full_name_to_users.rb b/db/migrate/20220505191606_add_full_name_to_users.rb new file mode 100644 index 00000000000..9d3a67ab6e3 --- /dev/null +++ b/db/migrate/20220505191606_add_full_name_to_users.rb @@ -0,0 +1,5 @@ +class AddFullNameToUsers < ActiveRecord::Migration[7.0] + def change + add_column :users, :full_name, :string + end +end diff --git a/db/migrate/20220609221942_remove_orphaned_owners.rb b/db/migrate/20220609221942_remove_orphaned_owners.rb new file mode 100644 index 00000000000..d78473d4a49 --- /dev/null +++ b/db/migrate/20220609221942_remove_orphaned_owners.rb @@ -0,0 +1,9 @@ +class RemoveOrphanedOwners < ActiveRecord::Migration[7.0] + def up + # Ownership.where.missing(:user).destroy_all + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/migrate/20220614221414_ownership_foreign_key.rb b/db/migrate/20220614221414_ownership_foreign_key.rb new file mode 100644 index 00000000000..2d2529ddfc9 --- /dev/null +++ b/db/migrate/20220614221414_ownership_foreign_key.rb @@ -0,0 +1,9 @@ +class OwnershipForeignKey < ActiveRecord::Migration[7.0] + def up + add_foreign_key :ownerships, :users, on_delete: :cascade + end + + def down + remove_foreign_key :ownerships, :users + end +end diff --git a/db/migrate/20221214191823_create_webauthn_verifications.rb b/db/migrate/20221214191823_create_webauthn_verifications.rb new file mode 100644 index 00000000000..f06b22b2ee5 --- /dev/null +++ b/db/migrate/20221214191823_create_webauthn_verifications.rb @@ -0,0 +1,13 @@ +class CreateWebauthnVerifications < ActiveRecord::Migration[7.0] + def change + create_table :webauthn_verifications do |t| + t.string :path_token, limit: 128 + t.datetime :path_token_expires_at + t.string :otp + t.datetime :otp_expires_at + t.references :user, null: false, index: { unique: true }, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/migrate/20230121005809_add_rubygem_to_position_versions_index.rb b/db/migrate/20230121005809_add_rubygem_to_position_versions_index.rb new file mode 100644 index 00000000000..9ea97ad32a0 --- /dev/null +++ b/db/migrate/20230121005809_add_rubygem_to_position_versions_index.rb @@ -0,0 +1,17 @@ +# NOTE: This migration can be deployed manually in advance to prevent DB locks using following SQL statements. +# Since it just adds new column to index, it works well with old ad new code at the same time. +# +# CREATE INDEX CONCURRENTLY index_versions_on_position_and_rubygem_id ON versions USING btree(position, rubygem_id); +# DROP INDEX CONCURRENTLY index_versions_on_position; +# INSERT INTO schema_migrations VALUES (('20230121005809')); +class AddRubygemToPositionVersionsIndex < ActiveRecord::Migration[7.0] + def up + add_index :versions, %i[position rubygem_id] + remove_index :versions, :position + end + + def down + add_index :versions, :position + remove_index :versions, %i[position rubygem_id] + end +end diff --git a/db/migrate/20230126163648_create_audits.rb b/db/migrate/20230126163648_create_audits.rb new file mode 100644 index 00000000000..9e0cd96b1f3 --- /dev/null +++ b/db/migrate/20230126163648_create_audits.rb @@ -0,0 +1,12 @@ +class CreateAudits < ActiveRecord::Migration[7.0] + def change + create_table :audits do |t| + t.references :auditable, polymorphic: true, index: true, null: false + t.belongs_to :admin_github_user, null: false + t.text :audited_changes + t.string :comment + t.string :action, null: false + t.timestamps + end + end +end diff --git a/db/migrate/20230203041509_create_admin_github_users.rb b/db/migrate/20230203041509_create_admin_github_users.rb new file mode 100644 index 00000000000..fef2946f59d --- /dev/null +++ b/db/migrate/20230203041509_create_admin_github_users.rb @@ -0,0 +1,15 @@ +class CreateAdminGitHubUsers < ActiveRecord::Migration[7.0] + def change + create_table :admin_github_users do |t| + t.string :login + t.string :avatar_url + t.string :github_id + t.json :info_data + t.string :oauth_token + t.boolean :is_admin + + t.timestamps + end + add_index :admin_github_users, :github_id, unique: true + end +end diff --git a/db/migrate/20230223002712_add_unique_versions_canonical_version_index.rb b/db/migrate/20230223002712_add_unique_versions_canonical_version_index.rb new file mode 100644 index 00000000000..04a9685d2a6 --- /dev/null +++ b/db/migrate/20230223002712_add_unique_versions_canonical_version_index.rb @@ -0,0 +1,7 @@ +class AddUniqueVersionsCanonicalVersionIndex < ActiveRecord::Migration[7.0] + disable_ddl_transaction! + + def change + add_index :versions, %i[canonical_number rubygem_id platform], unique: true, algorithm: :concurrently + end +end diff --git a/db/migrate/20230224175634_create_good_jobs.rb b/db/migrate/20230224175634_create_good_jobs.rb new file mode 100644 index 00000000000..50f451bd629 --- /dev/null +++ b/db/migrate/20230224175634_create_good_jobs.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +class CreateGoodJobs < ActiveRecord::Migration[7.0] + def change + enable_extension "pgcrypto" + + create_table :good_jobs, id: :uuid do |t| + t.text :queue_name + t.integer :priority + t.jsonb :serialized_params + t.datetime :scheduled_at + t.datetime :performed_at + t.datetime :finished_at + t.text :error + + t.timestamps + + t.uuid :active_job_id + t.text :concurrency_key + t.text :cron_key + t.uuid :retried_good_job_id + t.datetime :cron_at + + t.uuid :batch_id + t.uuid :batch_callback_id + end + + create_table :good_job_batches, id: :uuid do |t| + t.timestamps + t.text :description + t.jsonb :serialized_properties + t.text :on_finish + t.text :on_success + t.text :on_discard + t.text :callback_queue_name + t.integer :callback_priority + t.datetime :enqueued_at + t.datetime :discarded_at + t.datetime :finished_at + end + + create_table :good_job_processes, id: :uuid do |t| + t.timestamps + t.jsonb :state + end + + create_table :good_job_settings, id: :uuid do |t| + t.timestamps + t.text :key + t.jsonb :value + t.index :key, unique: true + end + + add_index :good_jobs, :scheduled_at, where: "(finished_at IS NULL)", name: "index_good_jobs_on_scheduled_at" + add_index :good_jobs, %i[queue_name scheduled_at], where: "(finished_at IS NULL)", name: :index_good_jobs_on_queue_name_and_scheduled_at + add_index :good_jobs, %i[active_job_id created_at], name: :index_good_jobs_on_active_job_id_and_created_at + add_index :good_jobs, :concurrency_key, where: "(finished_at IS NULL)", name: :index_good_jobs_on_concurrency_key_when_unfinished + add_index :good_jobs, %i[cron_key created_at], name: :index_good_jobs_on_cron_key_and_created_at + add_index :good_jobs, %i[cron_key cron_at], name: :index_good_jobs_on_cron_key_and_cron_at, unique: true + add_index :good_jobs, [:active_job_id], name: :index_good_jobs_on_active_job_id + add_index :good_jobs, [:finished_at], where: "retried_good_job_id IS NULL AND finished_at IS NOT NULL", name: :index_good_jobs_jobs_on_finished_at + add_index :good_jobs, %i[priority created_at], order: { priority: "DESC NULLS LAST", created_at: :asc }, + where: "finished_at IS NULL", name: :index_good_jobs_jobs_on_priority_created_at_when_unfinished + add_index :good_jobs, [:batch_id], where: "batch_id IS NOT NULL" + add_index :good_jobs, [:batch_callback_id], where: "batch_callback_id IS NOT NULL" + end +end diff --git a/db/migrate/20230306210044_add_tracking_to_web_hooks.rb b/db/migrate/20230306210044_add_tracking_to_web_hooks.rb new file mode 100644 index 00000000000..e9141c3225b --- /dev/null +++ b/db/migrate/20230306210044_add_tracking_to_web_hooks.rb @@ -0,0 +1,12 @@ +class AddTrackingToWebHooks < ActiveRecord::Migration[7.0] + def change + change_table(:web_hooks, bulk: true) do |t| + t.text :disabled_reason, null: true + t.timestamp :disabled_at, null: true + t.timestamp :last_success, null: true + t.timestamp :last_failure, null: true + t.integer :successes_since_last_failure, default: 0 + t.integer :failures_since_last_success, default: 0 + end + end +end diff --git a/db/migrate/20230324190734_drop_delayed_jobs.rb b/db/migrate/20230324190734_drop_delayed_jobs.rb new file mode 100644 index 00000000000..c62ec72f1c6 --- /dev/null +++ b/db/migrate/20230324190734_drop_delayed_jobs.rb @@ -0,0 +1,21 @@ +class DropDelayedJobs < ActiveRecord::Migration[7.0] + def up + drop_table :delayed_jobs + end + + def down + create_table "delayed_jobs", id: :serial, force: :cascade do |t| + t.integer "priority", default: 0 + t.integer "attempts", default: 0 + t.text "handler" + t.text "last_error" + t.datetime "run_at", precision: nil + t.datetime "locked_at", precision: nil + t.datetime "failed_at", precision: nil + t.string "locked_by" + t.datetime "created_at", precision: nil + t.datetime "updated_at", precision: nil + t.string "queue" + end + end +end diff --git a/db/migrate/20230404233417_create_oidc_providers.rb b/db/migrate/20230404233417_create_oidc_providers.rb new file mode 100644 index 00000000000..b75afbc2b74 --- /dev/null +++ b/db/migrate/20230404233417_create_oidc_providers.rb @@ -0,0 +1,11 @@ +class CreateOIDCProviders < ActiveRecord::Migration[7.0] + def change + create_table :oidc_providers do |t| + t.text :issuer, index: { unique: true } + t.jsonb :configuration + t.jsonb :jwks + + t.timestamps + end + end +end diff --git a/db/migrate/20230405000852_create_oidc_api_key_roles.rb b/db/migrate/20230405000852_create_oidc_api_key_roles.rb new file mode 100644 index 00000000000..714c27c3eac --- /dev/null +++ b/db/migrate/20230405000852_create_oidc_api_key_roles.rb @@ -0,0 +1,13 @@ +class CreateOIDCApiKeyRoles < ActiveRecord::Migration[7.0] + def change + create_table :oidc_api_key_roles do |t| + t.references :oidc_provider, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true + t.jsonb :api_key_permissions, null: false + t.string :name, null: false + t.jsonb :access_policy, null: false + + t.timestamps + end + end +end diff --git a/db/migrate/20230410033351_add_expires_at_to_api_keys.rb b/db/migrate/20230410033351_add_expires_at_to_api_keys.rb new file mode 100644 index 00000000000..b360d63968f --- /dev/null +++ b/db/migrate/20230410033351_add_expires_at_to_api_keys.rb @@ -0,0 +1,5 @@ +class AddExpiresAtToApiKeys < ActiveRecord::Migration[7.0] + def change + add_column :api_keys, :expires_at, :timestamp + end +end diff --git a/db/migrate/20230410052651_create_oidc_id_tokens.rb b/db/migrate/20230410052651_create_oidc_id_tokens.rb new file mode 100644 index 00000000000..fd33c7c6d2f --- /dev/null +++ b/db/migrate/20230410052651_create_oidc_id_tokens.rb @@ -0,0 +1,12 @@ +class CreateOIDCIdTokens < ActiveRecord::Migration[7.0] + def change + create_table :oidc_id_tokens do |t| + t.references :oidc_api_key_role, null: false, foreign_key: true + t.jsonb :jwt, null: false + t.references :oidc_provider, null: false, foreign_key: true + t.references :api_key, null: true, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/migrate/20230410063932_add_api_key_to_versions.rb b/db/migrate/20230410063932_add_api_key_to_versions.rb new file mode 100644 index 00000000000..b16fcefdab1 --- /dev/null +++ b/db/migrate/20230410063932_add_api_key_to_versions.rb @@ -0,0 +1,5 @@ +class AddApiKeyToVersions < ActiveRecord::Migration[7.0] + def change + add_reference :versions, :pusher_api_key, null: true, foreign_key: { to_table: :api_keys } + end +end diff --git a/db/migrate/20230424163744_create_good_job_settings.rb b/db/migrate/20230424163744_create_good_job_settings.rb new file mode 100644 index 00000000000..fa3540174a6 --- /dev/null +++ b/db/migrate/20230424163744_create_good_job_settings.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class CreateGoodJobSettings < ActiveRecord::Migration[7.0] + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.table_exists?(:good_job_settings) + end + end + + create_table :good_job_settings, id: :uuid do |t| + t.timestamps + t.text :key + t.jsonb :value + t.index :key, unique: true + end + end +end diff --git a/db/migrate/20230424163745_create_index_good_jobs_jobs_on_priority_created_at_when_unfinished.rb b/db/migrate/20230424163745_create_index_good_jobs_jobs_on_priority_created_at_when_unfinished.rb new file mode 100644 index 00000000000..c5f169991d4 --- /dev/null +++ b/db/migrate/20230424163745_create_index_good_jobs_jobs_on_priority_created_at_when_unfinished.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class CreateIndexGoodJobsJobsOnPriorityCreatedAtWhenUnfinished < ActiveRecord::Migration[7.0] + disable_ddl_transaction! + + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.index_name_exists?(:good_jobs, :index_good_jobs_jobs_on_priority_created_at_when_unfinished) + end + end + + add_index :good_jobs, %i[priority created_at], order: { priority: "DESC NULLS LAST", created_at: :asc }, + where: "finished_at IS NULL", name: :index_good_jobs_jobs_on_priority_created_at_when_unfinished, + algorithm: :concurrently + end +end diff --git a/db/migrate/20230424163746_create_good_job_batches.rb b/db/migrate/20230424163746_create_good_job_batches.rb new file mode 100644 index 00000000000..ac6c5b37e98 --- /dev/null +++ b/db/migrate/20230424163746_create_good_job_batches.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class CreateGoodJobBatches < ActiveRecord::Migration[7.0] + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.table_exists?(:good_job_batches) + end + end + + create_table :good_job_batches, id: :uuid do |t| + t.timestamps + t.text :description + t.jsonb :serialized_properties + t.text :on_finish + t.text :on_success + t.text :on_discard + t.text :callback_queue_name + t.integer :callback_priority + t.datetime :enqueued_at + t.datetime :discarded_at + t.datetime :finished_at + end + + change_table :good_jobs do |t| + t.uuid :batch_id + t.uuid :batch_callback_id + + t.index :batch_id, where: "batch_id IS NOT NULL" + t.index :batch_callback_id, where: "batch_callback_id IS NOT NULL" + end + end +end diff --git a/db/migrate/20230424163747_create_good_job_executions.rb b/db/migrate/20230424163747_create_good_job_executions.rb new file mode 100644 index 00000000000..7951c4896d6 --- /dev/null +++ b/db/migrate/20230424163747_create_good_job_executions.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class CreateGoodJobExecutions < ActiveRecord::Migration[7.0] + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.table_exists?(:good_job_executions) + end + end + + create_table :good_job_executions, id: :uuid do |t| + t.timestamps + + t.uuid :active_job_id, null: false + t.text :job_class + t.text :queue_name + t.jsonb :serialized_params + t.datetime :scheduled_at + t.datetime :finished_at + t.text :error + + t.index %i[active_job_id created_at], name: :index_good_job_executions_on_active_job_id_and_created_at + end + + change_table(:good_jobs, bulk: true) do |t| + t.boolean :is_discrete + t.integer :executions_count + t.text :job_class + end + end +end diff --git a/db/migrate/20230518182503_create_otp_seed_column.rb b/db/migrate/20230518182503_create_otp_seed_column.rb new file mode 100644 index 00000000000..0555bd433d4 --- /dev/null +++ b/db/migrate/20230518182503_create_otp_seed_column.rb @@ -0,0 +1,5 @@ +class CreateOtpSeedColumn < ActiveRecord::Migration[7.0] + def change + add_column :users, :totp_seed, :string + end +end diff --git a/db/migrate/20230609043749_remove_mfa_seed_from_users.rb b/db/migrate/20230609043749_remove_mfa_seed_from_users.rb new file mode 100644 index 00000000000..2b560cd0a64 --- /dev/null +++ b/db/migrate/20230609043749_remove_mfa_seed_from_users.rb @@ -0,0 +1,5 @@ +class RemoveMfaSeedFromUsers < ActiveRecord::Migration[7.0] + def change + remove_column :users, :mfa_seed # rubocop:disable Rails/ReversibleMigration + end +end diff --git a/db/migrate/20230702010946_create_gem_name_reservations.rb b/db/migrate/20230702010946_create_gem_name_reservations.rb new file mode 100644 index 00000000000..b81180a6653 --- /dev/null +++ b/db/migrate/20230702010946_create_gem_name_reservations.rb @@ -0,0 +1,72 @@ +class CreateGemNameReservations < ActiveRecord::Migration[7.0] + ORIGINAL_GEM_NAME_RESERVED_LIST = %w[ + cgi-session + complex + continuation + coverage + enumerator + expect + fiber + mkmf + profiler + pty + rational + rbconfig + socket + thread + unicode_normalize + ubygems + update_with_your_gem_name_prior_to_release_to_rubygems_org + update_with_your_gem_name_immediately_after_release_to_rubygems_org + + jruby + mri + mruby + ruby + rubygems + gem + update_rubygems + install + uninstall + sidekiq-pro + graphql-pro + + action-cable + action_cable + action-mailer + action_mailer + action-pack + action_pack + action-view + action_view + active-job + active_job + active-model + active_model + active-record + active_record + active-storage + active_storage + active-support + active_support + sprockets_rails + rail-ties + rail_ties + ].freeze + + def change + create_table :gem_name_reservations do |t| + t.string :name, null: false + + t.timestamps + end + + add_index :gem_name_reservations, :name, unique: true + + reversible do |change| + change.up do + # GemNameReservation.insert_all(ORIGINAL_GEM_NAME_RESERVED_LIST.map { |name| { name: name } }) + end + end + end +end diff --git a/db/migrate/20230708003340_add_mfa_hashed_recovery_codes_to_users.rb b/db/migrate/20230708003340_add_mfa_hashed_recovery_codes_to_users.rb new file mode 100644 index 00000000000..bd09936fc37 --- /dev/null +++ b/db/migrate/20230708003340_add_mfa_hashed_recovery_codes_to_users.rb @@ -0,0 +1,5 @@ +class AddMfaHashedRecoveryCodesToUsers < ActiveRecord::Migration[7.0] + def change + add_column :users, :mfa_hashed_recovery_codes, :string, default: [], array: true + end +end diff --git a/db/migrate/20230713023753_remove_mfa_recovery_codes_from_user.rb b/db/migrate/20230713023753_remove_mfa_recovery_codes_from_user.rb new file mode 100644 index 00000000000..cb8ffbc0f0b --- /dev/null +++ b/db/migrate/20230713023753_remove_mfa_recovery_codes_from_user.rb @@ -0,0 +1,5 @@ +class RemoveMfaRecoveryCodesFromUser < ActiveRecord::Migration[7.0] + def change + remove_column :users, :mfa_recovery_codes, :string, default: [], array: true + end +end diff --git a/db/migrate/20230719013340_add_token_to_oidc_api_key_role.rb b/db/migrate/20230719013340_add_token_to_oidc_api_key_role.rb new file mode 100644 index 00000000000..1fd6b2130e7 --- /dev/null +++ b/db/migrate/20230719013340_add_token_to_oidc_api_key_role.rb @@ -0,0 +1,6 @@ +class AddTokenToOIDCApiKeyRole < ActiveRecord::Migration[7.0] + def change + add_column :oidc_api_key_roles, :token, :string, null: false, unique: true, limit: 32 # rubocop:disable Rails/NotNullColumn + add_index :oidc_api_key_roles, :token, unique: true + end +end diff --git a/db/migrate/20230730185646_create_maintenance_tasks_runs.maintenance_tasks.rb b/db/migrate/20230730185646_create_maintenance_tasks_runs.maintenance_tasks.rb new file mode 100644 index 00000000000..d64a42d9893 --- /dev/null +++ b/db/migrate/20230730185646_create_maintenance_tasks_runs.maintenance_tasks.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# This migration comes from maintenance_tasks (originally 20201211151756) +class CreateMaintenanceTasksRuns < ActiveRecord::Migration[6.0] + def change + create_table(:maintenance_tasks_runs) do |t| + t.string(:task_name, null: false) + t.datetime(:started_at) + t.datetime(:ended_at) + t.float(:time_running, default: 0.0, null: false) + t.integer(:tick_count, default: 0, null: false) + t.integer(:tick_total) + t.string(:job_id) + t.bigint(:cursor) + t.string(:status, default: :enqueued, null: false) + t.string(:error_class) + t.string(:error_message) + t.text(:backtrace) + t.timestamps + t.index(:task_name) + t.index(%i[task_name created_at], order: { created_at: :desc }) + end + end +end diff --git a/db/migrate/20230730185647_remove_index_on_task_name.maintenance_tasks.rb b/db/migrate/20230730185647_remove_index_on_task_name.maintenance_tasks.rb new file mode 100644 index 00000000000..1b5cadf8dfa --- /dev/null +++ b/db/migrate/20230730185647_remove_index_on_task_name.maintenance_tasks.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# This migration comes from maintenance_tasks (originally 20210225152418) +class RemoveIndexOnTaskName < ActiveRecord::Migration[6.0] + def up + change_table(:maintenance_tasks_runs) do |t| + t.remove_index(:task_name) + end + end + + def down + change_table(:maintenance_tasks_runs) do |t| + t.index(:task_name) + end + end +end diff --git a/db/migrate/20230730185648_add_arguments_to_maintenance_tasks_runs.maintenance_tasks.rb b/db/migrate/20230730185648_add_arguments_to_maintenance_tasks_runs.maintenance_tasks.rb new file mode 100644 index 00000000000..8478268eba9 --- /dev/null +++ b/db/migrate/20230730185648_add_arguments_to_maintenance_tasks_runs.maintenance_tasks.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# This migration comes from maintenance_tasks (originally 20210517131953) +class AddArgumentsToMaintenanceTasksRuns < ActiveRecord::Migration[6.0] + def change + add_column(:maintenance_tasks_runs, :arguments, :text) + end +end diff --git a/db/migrate/20230730185649_add_lock_version_to_maintenance_tasks_runs.maintenance_tasks.rb b/db/migrate/20230730185649_add_lock_version_to_maintenance_tasks_runs.maintenance_tasks.rb new file mode 100644 index 00000000000..3782e472ec7 --- /dev/null +++ b/db/migrate/20230730185649_add_lock_version_to_maintenance_tasks_runs.maintenance_tasks.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# This migration comes from maintenance_tasks (originally 20211210152329) +class AddLockVersionToMaintenanceTasksRuns < ActiveRecord::Migration[6.0] + def change + add_column( + :maintenance_tasks_runs, + :lock_version, + :integer, + default: 0, + null: false + ) + end +end diff --git a/db/migrate/20230730185650_change_runs_tick_columns_to_bigints.maintenance_tasks.rb b/db/migrate/20230730185650_change_runs_tick_columns_to_bigints.maintenance_tasks.rb new file mode 100644 index 00000000000..064b0770573 --- /dev/null +++ b/db/migrate/20230730185650_change_runs_tick_columns_to_bigints.maintenance_tasks.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# This migration comes from maintenance_tasks (originally 20220706101937) +class ChangeRunsTickColumnsToBigints < ActiveRecord::Migration[6.0] + def up + change_table(:maintenance_tasks_runs, bulk: true) do |t| + t.change(:tick_count, :bigint) + t.change(:tick_total, :bigint) + end + end + + def down + change_table(:maintenance_tasks_runs, bulk: true) do |t| + t.change(:tick_count, :integer) + t.change(:tick_total, :integer) + end + end +end diff --git a/db/migrate/20230730185651_add_index_on_task_name_and_status_to_runs.maintenance_tasks.rb b/db/migrate/20230730185651_add_index_on_task_name_and_status_to_runs.maintenance_tasks.rb new file mode 100644 index 00000000000..adef827a969 --- /dev/null +++ b/db/migrate/20230730185651_add_index_on_task_name_and_status_to_runs.maintenance_tasks.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# This migration comes from maintenance_tasks (originally 20220713131925) +class AddIndexOnTaskNameAndStatusToRuns < ActiveRecord::Migration[6.0] + def change + remove_index( + :maintenance_tasks_runs, + column: %i[task_name created_at], + order: { created_at: :desc }, + name: :index_maintenance_tasks_runs_on_task_name_and_created_at + ) + + add_index( + :maintenance_tasks_runs, + %i[task_name status created_at], + name: :index_maintenance_tasks_runs, + order: { created_at: :desc } + ) + end +end diff --git a/db/migrate/20230803182938_change_cursor_to_string.maintenance_tasks.rb b/db/migrate/20230803182938_change_cursor_to_string.maintenance_tasks.rb new file mode 100644 index 00000000000..4e414cad3ab --- /dev/null +++ b/db/migrate/20230803182938_change_cursor_to_string.maintenance_tasks.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# This migration comes from maintenance_tasks (originally 20210219212931) +class ChangeCursorToString < ActiveRecord::Migration[6.0] + # This migration will clear all existing data in the cursor column with MySQL. + # Ensure no Tasks are paused when this migration is deployed, or they will be resumed from the start. + # Running tasks are able to gracefully handle this change, even if interrupted. + def up + change_table(:maintenance_tasks_runs) do |t| + t.change(:cursor, :string) + end + end + + def down + change_table(:maintenance_tasks_runs) do |t| + t.change(:cursor, :bigint) + end + end +end diff --git a/db/migrate/20230804215243_remove_oidc_provider_from_oidc_id_token.rb b/db/migrate/20230804215243_remove_oidc_provider_from_oidc_id_token.rb new file mode 100644 index 00000000000..72f143095b0 --- /dev/null +++ b/db/migrate/20230804215243_remove_oidc_provider_from_oidc_id_token.rb @@ -0,0 +1,5 @@ +class RemoveOIDCProviderFromOIDCIdToken < ActiveRecord::Migration[7.0] + def change + remove_column :oidc_id_tokens, :oidc_provider_id, null: false + end +end diff --git a/db/migrate/20230823060929_add_gem_platform_and_gem_full_name_to_versions.rb b/db/migrate/20230823060929_add_gem_platform_and_gem_full_name_to_versions.rb new file mode 100644 index 00000000000..a20e3126b97 --- /dev/null +++ b/db/migrate/20230823060929_add_gem_platform_and_gem_full_name_to_versions.rb @@ -0,0 +1,8 @@ +class AddGemPlatformAndGemFullNameToVersions < ActiveRecord::Migration[7.0] + def change + change_table(:versions, bulk: true) do |t| + t.string :gem_platform + t.string :gem_full_name + end + end +end diff --git a/db/migrate/20230824182001_remove_slug_from_rubygems.rb b/db/migrate/20230824182001_remove_slug_from_rubygems.rb new file mode 100644 index 00000000000..bd9476aed5c --- /dev/null +++ b/db/migrate/20230824182001_remove_slug_from_rubygems.rb @@ -0,0 +1,5 @@ +class RemoveSlugFromRubygems < ActiveRecord::Migration[7.0] + def change + remove_column :rubygems, :slug, :string + end +end diff --git a/db/migrate/20230825183208_add_metadata_to_runs.maintenance_tasks.rb b/db/migrate/20230825183208_add_metadata_to_runs.maintenance_tasks.rb new file mode 100644 index 00000000000..054ef0e2a97 --- /dev/null +++ b/db/migrate/20230825183208_add_metadata_to_runs.maintenance_tasks.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# This migration comes from maintenance_tasks (originally 20230622035229) +class AddMetadataToRuns < ActiveRecord::Migration[6.0] + def change + add_column(:maintenance_tasks_runs, :metadata, :text) + end +end diff --git a/db/migrate/20230825183453_create_good_jobs_error_event.rb b/db/migrate/20230825183453_create_good_jobs_error_event.rb new file mode 100644 index 00000000000..b07e0f14e7f --- /dev/null +++ b/db/migrate/20230825183453_create_good_jobs_error_event.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateGoodJobsErrorEvent < ActiveRecord::Migration[7.0] + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.column_exists?(:good_jobs, :error_event) + end + end + + add_column :good_jobs, :error_event, :integer, limit: 2 + add_column :good_job_executions, :error_event, :integer, limit: 2 + end +end diff --git a/db/migrate/20230830014805_add_spec_sha256_to_version.rb b/db/migrate/20230830014805_add_spec_sha256_to_version.rb new file mode 100644 index 00000000000..9134d1c05aa --- /dev/null +++ b/db/migrate/20230830014805_add_spec_sha256_to_version.rb @@ -0,0 +1,5 @@ +class AddSpecSha256ToVersion < ActiveRecord::Migration[7.0] + def change + add_column :versions, :spec_sha256, :string, limit: 44 + end +end diff --git a/db/migrate/20230830194257_add_public_email_column.rb b/db/migrate/20230830194257_add_public_email_column.rb new file mode 100644 index 00000000000..13b8ebeafd3 --- /dev/null +++ b/db/migrate/20230830194257_add_public_email_column.rb @@ -0,0 +1,9 @@ +class AddPublicEmailColumn < ActiveRecord::Migration[7.0] + def up + add_column :users, :public_email, :boolean, default: false, null: false + end + + def down + remove_column :users, :public_email + end +end diff --git a/db/migrate/20230905152206_create_link_verifications.rb b/db/migrate/20230905152206_create_link_verifications.rb new file mode 100644 index 00000000000..55a60cadb3c --- /dev/null +++ b/db/migrate/20230905152206_create_link_verifications.rb @@ -0,0 +1,15 @@ +class CreateLinkVerifications < ActiveRecord::Migration[7.0] + def change + create_table :link_verifications do |t| + t.references :linkable, polymorphic: true, null: false + t.string :uri, null: false + t.datetime :last_verified_at, null: true + t.datetime :last_failure_at, null: true + t.integer :failures_since_last_verification, default: 0 + + t.timestamps + + t.index %w[linkable_id linkable_type uri], name: "index_link_verifications_on_linkable_and_uri" + end + end +end diff --git a/db/migrate/20230926202658_index_versions_on_lower_gem_full_name.rb b/db/migrate/20230926202658_index_versions_on_lower_gem_full_name.rb new file mode 100644 index 00000000000..09d3ff532ef --- /dev/null +++ b/db/migrate/20230926202658_index_versions_on_lower_gem_full_name.rb @@ -0,0 +1,7 @@ +class IndexVersionsOnLowerGemFullName < ActiveRecord::Migration[7.0] + disable_ddl_transaction! + + def change + add_index :versions, "lower((gem_full_name)::text)", name: "index_versions_on_lower_gem_full_name", algorithm: :concurrently + end +end diff --git a/db/migrate/20231018235829_add_deleted_at_to_oidc_api_key_role.rb b/db/migrate/20231018235829_add_deleted_at_to_oidc_api_key_role.rb new file mode 100644 index 00000000000..9f5b77aa4b0 --- /dev/null +++ b/db/migrate/20231018235829_add_deleted_at_to_oidc_api_key_role.rb @@ -0,0 +1,5 @@ +class AddDeletedAtToOIDCApiKeyRole < ActiveRecord::Migration[7.0] + def change + add_column :oidc_api_key_roles, :deleted_at, :datetime + end +end diff --git a/db/migrate/20231027190405_create_oidc_trusted_publisher_github_actions.rb b/db/migrate/20231027190405_create_oidc_trusted_publisher_github_actions.rb new file mode 100644 index 00000000000..3140eab0e8e --- /dev/null +++ b/db/migrate/20231027190405_create_oidc_trusted_publisher_github_actions.rb @@ -0,0 +1,17 @@ +class CreateOIDCTrustedPublisherGitHubActions < ActiveRecord::Migration[7.0] + def change + create_table :oidc_trusted_publisher_github_actions do |t| + t.string :repository_owner, null: false + t.string :repository_name, null: false + t.string :repository_owner_id, null: false + t.string :workflow_filename, null: false + t.string :environment, null: true + + t.timestamps + end + + add_index :oidc_trusted_publisher_github_actions, + %i[repository_owner repository_name repository_owner_id workflow_filename environment], + unique: true, name: "index_oidc_trusted_publisher_github_actions_claims" + end +end diff --git a/db/migrate/20231027191446_create_oidc_rubygem_trusted_publishers.rb b/db/migrate/20231027191446_create_oidc_rubygem_trusted_publishers.rb new file mode 100644 index 00000000000..bd8cfd18031 --- /dev/null +++ b/db/migrate/20231027191446_create_oidc_rubygem_trusted_publishers.rb @@ -0,0 +1,14 @@ +class CreateOIDCRubygemTrustedPublishers < ActiveRecord::Migration[7.0] + def change + create_table :oidc_rubygem_trusted_publishers do |t| + t.references :rubygem, null: false, foreign_key: true + t.references :trusted_publisher, polymorphic: true, null: false + + t.timestamps + end + + add_index :oidc_rubygem_trusted_publishers, + %i[rubygem_id trusted_publisher_id trusted_publisher_type], + unique: true, name: "index_oidc_rubygem_trusted_publishers_unique" + end +end diff --git a/db/migrate/20231102190427_add_owner_to_api_keys.rb b/db/migrate/20231102190427_add_owner_to_api_keys.rb new file mode 100644 index 00000000000..f3847804a56 --- /dev/null +++ b/db/migrate/20231102190427_add_owner_to_api_keys.rb @@ -0,0 +1,7 @@ +class AddOwnerToApiKeys < ActiveRecord::Migration[7.0] + disable_ddl_transaction! + + def change + add_reference :api_keys, :owner, polymorphic: true, null: true, index: { algorithm: :concurrently } + end +end diff --git a/db/migrate/20231108205146_create_oidc_pending_trusted_publishers.rb b/db/migrate/20231108205146_create_oidc_pending_trusted_publishers.rb new file mode 100644 index 00000000000..903524f64e3 --- /dev/null +++ b/db/migrate/20231108205146_create_oidc_pending_trusted_publishers.rb @@ -0,0 +1,12 @@ +class CreateOIDCPendingTrustedPublishers < ActiveRecord::Migration[7.0] + def change + create_table :oidc_pending_trusted_publishers do |t| + t.string :rubygem_name + t.references :user, null: false, foreign_key: true + t.references :trusted_publisher, null: false, polymorphic: true + t.timestamp :expires_at, null: false + + t.timestamps + end + end +end diff --git a/db/migrate/20231120032536_change_api_key_user_id_to_null.rb b/db/migrate/20231120032536_change_api_key_user_id_to_null.rb new file mode 100644 index 00000000000..7afcb3d87b6 --- /dev/null +++ b/db/migrate/20231120032536_change_api_key_user_id_to_null.rb @@ -0,0 +1,5 @@ +class ChangeApiKeyUserIdToNull < ActiveRecord::Migration[7.0] + def change + change_column_null :api_keys, :user_id, true + end +end diff --git a/db/migrate/20231120033231_change_api_key_owner_to_not_null.rb b/db/migrate/20231120033231_change_api_key_owner_to_not_null.rb new file mode 100644 index 00000000000..a3cb7d498ca --- /dev/null +++ b/db/migrate/20231120033231_change_api_key_owner_to_not_null.rb @@ -0,0 +1,6 @@ +class ChangeApiKeyOwnerToNotNull < ActiveRecord::Migration[7.0] + def change + add_check_constraint :api_keys, "owner_id IS NOT NULL", name: "api_keys_owner_id_null", validate: false + add_check_constraint :api_keys, "owner_type IS NOT NULL", name: "api_keys_owner_type_null", validate: false + end +end diff --git a/db/migrate/20231120033411_validate_change_api_key_owner_to_not_null.rb b/db/migrate/20231120033411_validate_change_api_key_owner_to_not_null.rb new file mode 100644 index 00000000000..a4529a44f31 --- /dev/null +++ b/db/migrate/20231120033411_validate_change_api_key_owner_to_not_null.rb @@ -0,0 +1,6 @@ +class ValidateChangeApiKeyOwnerToNotNull < ActiveRecord::Migration[7.0] + def change + validate_check_constraint :api_keys, name: "api_keys_owner_id_null" + validate_check_constraint :api_keys, name: "api_keys_owner_type_null" + end +end diff --git a/db/migrate/20231129233528_add_version_id_to_deletions.rb b/db/migrate/20231129233528_add_version_id_to_deletions.rb new file mode 100644 index 00000000000..a7a4ddb9c04 --- /dev/null +++ b/db/migrate/20231129233528_add_version_id_to_deletions.rb @@ -0,0 +1,7 @@ +class AddVersionIdToDeletions < ActiveRecord::Migration[7.0] + disable_ddl_transaction! + + def change + add_reference :deletions, :version, index: { algorithm: :concurrently } + end +end diff --git a/db/migrate/20231130000000_remove_user_id_from_api_keys.rb b/db/migrate/20231130000000_remove_user_id_from_api_keys.rb new file mode 100644 index 00000000000..37f3d30626a --- /dev/null +++ b/db/migrate/20231130000000_remove_user_id_from_api_keys.rb @@ -0,0 +1,5 @@ +class RemoveUserIdFromApiKeys < ActiveRecord::Migration[7.0] + def change + safety_assured { remove_column :api_keys, :user_id, :integer } + end +end diff --git a/db/migrate/20231208004220_index_users_webauthn_id.rb b/db/migrate/20231208004220_index_users_webauthn_id.rb new file mode 100644 index 00000000000..45a286b3070 --- /dev/null +++ b/db/migrate/20231208004220_index_users_webauthn_id.rb @@ -0,0 +1,7 @@ +class IndexUsersWebauthnId < ActiveRecord::Migration[7.0] + disable_ddl_transaction! + + def change + add_index :users, :webauthn_id, unique: true, algorithm: :concurrently + end +end diff --git a/db/migrate/20240110052612_recreate_good_job_cron_indexes_with_conditional.rb b/db/migrate/20240110052612_recreate_good_job_cron_indexes_with_conditional.rb new file mode 100644 index 00000000000..8d7393d933d --- /dev/null +++ b/db/migrate/20240110052612_recreate_good_job_cron_indexes_with_conditional.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class RecreateGoodJobCronIndexesWithConditional < ActiveRecord::Migration[7.0] + disable_ddl_transaction! + + def change + reversible do |dir| + dir.up do + unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_created_at_cond) + add_index :good_jobs, %i[cron_key created_at], where: "(cron_key IS NOT NULL)", + name: :index_good_jobs_on_cron_key_and_created_at_cond, algorithm: :concurrently + end + unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_cron_at_cond) + add_index :good_jobs, %i[cron_key cron_at], where: "(cron_key IS NOT NULL)", unique: true, + name: :index_good_jobs_on_cron_key_and_cron_at_cond, algorithm: :concurrently + end + + if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_created_at) + remove_index :good_jobs, name: :index_good_jobs_on_cron_key_and_created_at + end + if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_cron_at) + remove_index :good_jobs, name: :index_good_jobs_on_cron_key_and_cron_at + end + end + + dir.down do + unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_created_at) + add_index :good_jobs, %i[cron_key created_at], + name: :index_good_jobs_on_cron_key_and_created_at, algorithm: :concurrently + end + unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_cron_at) + add_index :good_jobs, %i[cron_key cron_at], unique: true, + name: :index_good_jobs_on_cron_key_and_cron_at, algorithm: :concurrently + end + + if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_created_at_cond) + remove_index :good_jobs, name: :index_good_jobs_on_cron_key_and_created_at_cond + end + if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_cron_at_cond) + remove_index :good_jobs, name: :index_good_jobs_on_cron_key_and_cron_at_cond + end + end + end + end +end diff --git a/db/migrate/20240110052613_create_good_job_labels.rb b/db/migrate/20240110052613_create_good_job_labels.rb new file mode 100644 index 00000000000..4720ae36428 --- /dev/null +++ b/db/migrate/20240110052613_create_good_job_labels.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateGoodJobLabels < ActiveRecord::Migration[7.0] + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.column_exists?(:good_jobs, :labels) + end + end + + add_column :good_jobs, :labels, :text, array: true + end +end diff --git a/db/migrate/20240110052614_create_good_job_labels_index.rb b/db/migrate/20240110052614_create_good_job_labels_index.rb new file mode 100644 index 00000000000..7adbeb12fef --- /dev/null +++ b/db/migrate/20240110052614_create_good_job_labels_index.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class CreateGoodJobLabelsIndex < ActiveRecord::Migration[7.0] + disable_ddl_transaction! + + def change + reversible do |dir| + dir.up do + unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_labels) + add_index :good_jobs, :labels, using: :gin, where: "(labels IS NOT NULL)", + name: :index_good_jobs_on_labels, algorithm: :concurrently + end + end + + dir.down do + remove_index :good_jobs, name: :index_good_jobs_on_labels if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_labels) + end + end + end +end diff --git a/db/migrate/20240110052615_remove_good_job_active_id_index.rb b/db/migrate/20240110052615_remove_good_job_active_id_index.rb new file mode 100644 index 00000000000..8160369a592 --- /dev/null +++ b/db/migrate/20240110052615_remove_good_job_active_id_index.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class RemoveGoodJobActiveIdIndex < ActiveRecord::Migration[7.0] + disable_ddl_transaction! + + def change + reversible do |dir| + dir.up do + if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_active_job_id) + remove_index :good_jobs, name: :index_good_jobs_on_active_job_id + end + end + + dir.down do + unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_active_job_id) + add_index :good_jobs, :active_job_id, name: :index_good_jobs_on_active_job_id + end + end + end + end +end diff --git a/db/migrate/20240117200013_create_geoip_infos.rb b/db/migrate/20240117200013_create_geoip_infos.rb new file mode 100644 index 00000000000..40e1ebbc0ea --- /dev/null +++ b/db/migrate/20240117200013_create_geoip_infos.rb @@ -0,0 +1,16 @@ +class CreateGeoipInfos < ActiveRecord::Migration[7.1] + def change + create_table :geoip_infos do |t| + t.string :continent_code, limit: 2, null: true + t.string :country_code, limit: 2, null: true + t.string :country_code3, limit: 3, null: true + t.string :country_name, null: true + t.string :region, null: true + t.string :city, null: true + + t.timestamps + + t.index %w[continent_code country_code country_code3 country_name region city], unique: true, name: "index_geoip_infos_on_fields" + end + end +end diff --git a/db/migrate/20240117200014_create_ip_addresses.rb b/db/migrate/20240117200014_create_ip_addresses.rb new file mode 100644 index 00000000000..c27c45ff0a0 --- /dev/null +++ b/db/migrate/20240117200014_create_ip_addresses.rb @@ -0,0 +1,11 @@ +class CreateIpAddresses < ActiveRecord::Migration[7.0] + def change + create_table :ip_addresses do |t| + t.inet :ip_address, null: false, index: { unique: true } + t.text :hashed_ip_address, null: false, index: { unique: true }, limit: 44 + t.belongs_to :geoip_info, null: true, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/migrate/20240117200406_create_events_user_events.rb b/db/migrate/20240117200406_create_events_user_events.rb new file mode 100644 index 00000000000..ceebd0a99a9 --- /dev/null +++ b/db/migrate/20240117200406_create_events_user_events.rb @@ -0,0 +1,14 @@ +class CreateEventsUserEvents < ActiveRecord::Migration[7.0] + def change + create_table :events_user_events do |t| + t.string :tag, null: false, index: true + t.string :trace_id, null: true + t.references :user, null: false, foreign_key: true + t.references :ip_address, null: true, foreign_key: true + t.references :geoip_info, null: true, foreign_key: true + t.jsonb :additional + + t.timestamps + end + end +end diff --git a/db/migrate/20240120010424_add_deleted_at_to_user.rb b/db/migrate/20240120010424_add_deleted_at_to_user.rb new file mode 100644 index 00000000000..a7c587e2950 --- /dev/null +++ b/db/migrate/20240120010424_add_deleted_at_to_user.rb @@ -0,0 +1,5 @@ +class AddDeletedAtToUser < ActiveRecord::Migration[7.1] + def change + add_column :users, :deleted_at, :datetime + end +end diff --git a/db/migrate/20240124184748_create_index_good_job_jobs_for_candidate_lookup.rb b/db/migrate/20240124184748_create_index_good_job_jobs_for_candidate_lookup.rb new file mode 100644 index 00000000000..082a8407df5 --- /dev/null +++ b/db/migrate/20240124184748_create_index_good_job_jobs_for_candidate_lookup.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class CreateIndexGoodJobJobsForCandidateLookup < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.index_name_exists?(:good_jobs, :index_good_job_jobs_for_candidate_lookup) + end + end + + add_index :good_jobs, %i[priority created_at], order: { priority: "ASC NULLS LAST", created_at: :asc }, + where: "finished_at IS NULL", name: :index_good_job_jobs_for_candidate_lookup, + algorithm: :concurrently + end +end diff --git a/db/migrate/20240125010637_create_events_rubygem_events.rb b/db/migrate/20240125010637_create_events_rubygem_events.rb new file mode 100644 index 00000000000..5f60c484886 --- /dev/null +++ b/db/migrate/20240125010637_create_events_rubygem_events.rb @@ -0,0 +1,14 @@ +class CreateEventsRubygemEvents < ActiveRecord::Migration[7.1] + def change + create_table :events_rubygem_events do |t| + t.string :tag, null: false, index: true + t.string :trace_id, null: true + t.references :rubygem, null: false, foreign_key: true + t.references :ip_address, null: true, foreign_key: true + t.references :geoip_info, null: true, foreign_key: true + t.jsonb :additional + + t.timestamps + end + end +end diff --git a/db/migrate/20240221235930_remove_unneeded_indexes.rb b/db/migrate/20240221235930_remove_unneeded_indexes.rb new file mode 100644 index 00000000000..c8ccb835352 --- /dev/null +++ b/db/migrate/20240221235930_remove_unneeded_indexes.rb @@ -0,0 +1,18 @@ +class RemoveUnneededIndexes < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def change + # covered by index_oidc_rubygem_trusted_publishers_unique + remove_index :oidc_rubygem_trusted_publishers, name: "index_oidc_rubygem_trusted_publishers_on_rubygem_id", column: :rubygem_id, + algorithm: :concurrently + + # covered by index_ownerships_on_user_id_and_rubygem_id + remove_index :ownerships, name: "index_ownerships_on_user_id", column: :user_id, algorithm: :concurrently + + # covered by index_versions_on_indexed_and_yanked_at + remove_index :versions, name: "index_versions_on_indexed", column: :indexed, algorithm: :concurrently + + # covered by index_versions_on_rubygem_id_and_number_and_platform + remove_index :versions, name: "index_versions_on_rubygem_id", column: :rubygem_id, algorithm: :concurrently + end +end diff --git a/db/migrate/20240327004732_add_foreign_keys.rb b/db/migrate/20240327004732_add_foreign_keys.rb new file mode 100644 index 00000000000..ca681b43e94 --- /dev/null +++ b/db/migrate/20240327004732_add_foreign_keys.rb @@ -0,0 +1,14 @@ +class AddForeignKeys < ActiveRecord::Migration[7.1] + def change + add_foreign_key "api_key_rubygem_scopes", "api_keys", name: "api_key_rubygem_scopes_api_key_id_fk", validate: false + add_foreign_key "audits", "admin_github_users", name: "audits_admin_github_user_id_fk", validate: false + add_foreign_key "ownership_calls", "rubygems", name: "ownership_calls_rubygem_id_fk", validate: false + add_foreign_key "ownership_calls", "users", name: "ownership_calls_user_id_fk", validate: false + add_foreign_key "ownership_requests", "users", column: "approver_id", name: "ownership_requests_approver_id_fk", validate: false + add_foreign_key "ownership_requests", "ownership_calls", name: "ownership_requests_ownership_call_id_fk", validate: false + add_foreign_key "ownership_requests", "rubygems", name: "ownership_requests_rubygem_id_fk", validate: false + add_foreign_key "ownership_requests", "users", name: "ownership_requests_user_id_fk", validate: false + add_foreign_key "versions", "rubygems", name: "versions_rubygem_id_fk", validate: false + add_foreign_key "web_hooks", "users", name: "web_hooks_user_id_fk", validate: false + end +end diff --git a/db/migrate/20240327005038_validate_new_keys.rb b/db/migrate/20240327005038_validate_new_keys.rb new file mode 100644 index 00000000000..13d28610826 --- /dev/null +++ b/db/migrate/20240327005038_validate_new_keys.rb @@ -0,0 +1,14 @@ +class ValidateNewKeys < ActiveRecord::Migration[7.1] + def change + validate_foreign_key "api_key_rubygem_scopes", "api_keys", name: "api_key_rubygem_scopes_api_key_id_fk" + validate_foreign_key "audits", "admin_github_users", name: "audits_admin_github_user_id_fk" + validate_foreign_key "ownership_calls", "rubygems", name: "ownership_calls_rubygem_id_fk" + validate_foreign_key "ownership_calls", "users", name: "ownership_calls_user_id_fk" + validate_foreign_key "ownership_requests", "users", column: "approver_id", name: "ownership_requests_approver_id_fk" + validate_foreign_key "ownership_requests", "ownership_calls", name: "ownership_requests_ownership_call_id_fk" + validate_foreign_key "ownership_requests", "rubygems", name: "ownership_requests_rubygem_id_fk" + validate_foreign_key "ownership_requests", "users", name: "ownership_requests_user_id_fk" + validate_foreign_key "versions", "rubygems", name: "versions_rubygem_id_fk" + validate_foreign_key "web_hooks", "users", name: "web_hooks_user_id_fk" + end +end diff --git a/db/migrate/20240328003844_add_linkset_foreign_key.rb b/db/migrate/20240328003844_add_linkset_foreign_key.rb new file mode 100644 index 00000000000..9f98f960590 --- /dev/null +++ b/db/migrate/20240328003844_add_linkset_foreign_key.rb @@ -0,0 +1,5 @@ +class AddLinksetForeignKey < ActiveRecord::Migration[7.1] + def change + add_foreign_key "linksets", "rubygems", name: "linksets_rubygem_id_fk", validate: false + end +end diff --git a/db/migrate/20240328004017_validate_linkset_foreign_key.rb b/db/migrate/20240328004017_validate_linkset_foreign_key.rb new file mode 100644 index 00000000000..6d42471cde1 --- /dev/null +++ b/db/migrate/20240328004017_validate_linkset_foreign_key.rb @@ -0,0 +1,5 @@ +class ValidateLinksetForeignKey < ActiveRecord::Migration[7.1] + def change + validate_foreign_key "linksets", "rubygems", name: "linksets_rubygem_id_fk" + end +end diff --git a/db/migrate/20240415064754_add_api_scopes_to_api_keys.rb b/db/migrate/20240415064754_add_api_scopes_to_api_keys.rb new file mode 100644 index 00000000000..e34c268fe75 --- /dev/null +++ b/db/migrate/20240415064754_add_api_scopes_to_api_keys.rb @@ -0,0 +1,5 @@ +class AddApiScopesToApiKeys < ActiveRecord::Migration[7.1] + def change + add_column :api_keys, :scopes, :string, array: true + end +end diff --git a/db/migrate/20240425190438_create_good_job_execution_error_backtrace.rb b/db/migrate/20240425190438_create_good_job_execution_error_backtrace.rb new file mode 100644 index 00000000000..6b054b64060 --- /dev/null +++ b/db/migrate/20240425190438_create_good_job_execution_error_backtrace.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateGoodJobExecutionErrorBacktrace < ActiveRecord::Migration[7.1] + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.column_exists?(:good_job_executions, :error_backtrace) + end + end + + add_column :good_job_executions, :error_backtrace, :text, array: true + end +end diff --git a/db/migrate/20240506180700_add_non_null_constraint_to_api_key_scopes.rb b/db/migrate/20240506180700_add_non_null_constraint_to_api_key_scopes.rb new file mode 100644 index 00000000000..22b307134a0 --- /dev/null +++ b/db/migrate/20240506180700_add_non_null_constraint_to_api_key_scopes.rb @@ -0,0 +1,5 @@ +class AddNonNullConstraintToApiKeyScopes < ActiveRecord::Migration[7.1] + def change + add_check_constraint :api_keys, "scopes IS NOT NULL", name: "api_keys_scopes_null", validate: false + end +end diff --git a/db/migrate/20240506180817_validate_non_null_constraint_to_api_key_scopes.rb b/db/migrate/20240506180817_validate_non_null_constraint_to_api_key_scopes.rb new file mode 100644 index 00000000000..16c719f8b80 --- /dev/null +++ b/db/migrate/20240506180817_validate_non_null_constraint_to_api_key_scopes.rb @@ -0,0 +1,5 @@ +class ValidateNonNullConstraintToApiKeyScopes < ActiveRecord::Migration[7.1] + def change + validate_check_constraint :api_keys, name: "api_keys_scopes_null" + end +end diff --git a/db/migrate/20240507181615_remove_legacy_api_key_scope_columns_from_api_keys.rb b/db/migrate/20240507181615_remove_legacy_api_key_scope_columns_from_api_keys.rb new file mode 100644 index 00000000000..753fbfdb367 --- /dev/null +++ b/db/migrate/20240507181615_remove_legacy_api_key_scope_columns_from_api_keys.rb @@ -0,0 +1,12 @@ +class RemoveLegacyApiKeyScopeColumnsFromApiKeys < ActiveRecord::Migration[7.1] + def change + # The columns are ignored + safety_assured do + remove_columns :api_keys, + *%i[show_dashboard index_rubygems push_rubygem yank_rubygem add_owner remove_owner access_webhooks], + type: :boolean, + null: false, + default: false + end + end +end diff --git a/db/migrate/20240522185716_create_good_job_process_lock_ids.rb b/db/migrate/20240522185716_create_good_job_process_lock_ids.rb new file mode 100644 index 00000000000..f1b70a8f2e4 --- /dev/null +++ b/db/migrate/20240522185716_create_good_job_process_lock_ids.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class CreateGoodJobProcessLockIds < ActiveRecord::Migration[7.1] + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.column_exists?(:good_jobs, :locked_by_id) + end + end + + add_column :good_jobs, :locked_by_id, :uuid + add_column :good_jobs, :locked_at, :datetime + add_column :good_job_executions, :process_id, :uuid + add_column :good_job_processes, :lock_type, :integer, limit: 2 + end +end diff --git a/db/migrate/20240522185717_create_good_job_process_lock_indexes.rb b/db/migrate/20240522185717_create_good_job_process_lock_indexes.rb new file mode 100644 index 00000000000..7b9b52a34ff --- /dev/null +++ b/db/migrate/20240522185717_create_good_job_process_lock_indexes.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class CreateGoodJobProcessLockIndexes < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def change + reversible do |dir| + dir.up do + unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked) + add_index :good_jobs, %i[priority scheduled_at], + order: { priority: "ASC NULLS LAST", scheduled_at: :asc }, + where: "finished_at IS NULL AND locked_by_id IS NULL", + name: :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked, + algorithm: :concurrently + end + + unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_locked_by_id) + add_index :good_jobs, :locked_by_id, + where: "locked_by_id IS NOT NULL", + name: :index_good_jobs_on_locked_by_id, + algorithm: :concurrently + end + + unless connection.index_name_exists?(:good_job_executions, :index_good_job_executions_on_process_id_and_created_at) + add_index :good_job_executions, %i[process_id created_at], + name: :index_good_job_executions_on_process_id_and_created_at, + algorithm: :concurrently + end + end + + dir.down do + remove_index(:good_jobs, name: :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked) if connection.index_name_exists?(:good_jobs, +:index_good_jobs_on_priority_scheduled_at_unfinished_unlocked) + remove_index(:good_jobs, name: :index_good_jobs_on_locked_by_id) if connection.index_name_exists?(:good_jobs, +:index_good_jobs_on_locked_by_id) + remove_index(:good_job_executions, name: :index_good_job_executions_on_process_id_and_created_at) if connection.index_name_exists?( + :good_job_executions, :index_good_job_executions_on_process_id_and_created_at + ) + end + end + end +end diff --git a/db/migrate/20240630025625_create_organizations.rb b/db/migrate/20240630025625_create_organizations.rb new file mode 100644 index 00000000000..66e7228c92d --- /dev/null +++ b/db/migrate/20240630025625_create_organizations.rb @@ -0,0 +1,13 @@ +class CreateOrganizations < ActiveRecord::Migration[7.1] + def change + create_table :organizations do |t| + t.string :handle, limit: 40 + t.string :name, limit: 255 + t.timestamp :deleted_at + + t.timestamps + + t.index "lower(handle)", unique: true + end + end +end diff --git a/db/migrate/20240630025804_create_memberships.rb b/db/migrate/20240630025804_create_memberships.rb new file mode 100644 index 00000000000..ee39a70ece8 --- /dev/null +++ b/db/migrate/20240630025804_create_memberships.rb @@ -0,0 +1,12 @@ +class CreateMemberships < ActiveRecord::Migration[7.1] + def change + create_table :memberships do |t| + t.belongs_to :user, null: false, foreign_key: true + t.belongs_to :organization, null: false, foreign_key: true + t.timestamp :confirmed_at, default: nil + + t.timestamps + t.index %i[user_id organization_id], unique: true + end + end +end diff --git a/db/migrate/20240712003336_create_organization_events.rb b/db/migrate/20240712003336_create_organization_events.rb new file mode 100644 index 00000000000..03cb1b95eaa --- /dev/null +++ b/db/migrate/20240712003336_create_organization_events.rb @@ -0,0 +1,14 @@ +class CreateOrganizationEvents < ActiveRecord::Migration[7.0] + def change + create_table :events_organization_events do |t| + t.string :tag, null: false, index: true + t.string :trace_id, null: true + t.references :organization, null: false, foreign_key: true + t.references :ip_address, null: true, foreign_key: true + t.references :geoip_info, null: true, foreign_key: true + t.jsonb :additional + + t.timestamps + end + end +end diff --git a/db/migrate/20240722182907_create_good_job_execution_duration.rb b/db/migrate/20240722182907_create_good_job_execution_duration.rb new file mode 100644 index 00000000000..fef37f07bc1 --- /dev/null +++ b/db/migrate/20240722182907_create_good_job_execution_duration.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateGoodJobExecutionDuration < ActiveRecord::Migration[7.1] + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.column_exists?(:good_job_executions, :duration) + end + end + + add_column :good_job_executions, :duration, :interval + end +end diff --git a/db/migrate/20240802151324_add_role_to_ownership.rb b/db/migrate/20240802151324_add_role_to_ownership.rb new file mode 100644 index 00000000000..236a6040c4d --- /dev/null +++ b/db/migrate/20240802151324_add_role_to_ownership.rb @@ -0,0 +1,5 @@ +class AddRoleToOwnership < ActiveRecord::Migration[7.1] + def change + add_column :ownerships, :role, :integer, null: false, default: 70 # Access::OWNER + end +end diff --git a/db/migrate/20240917042436_add_role_to_memberships.rb b/db/migrate/20240917042436_add_role_to_memberships.rb new file mode 100644 index 00000000000..1a1d311325a --- /dev/null +++ b/db/migrate/20240917042436_add_role_to_memberships.rb @@ -0,0 +1,5 @@ +class AddRoleToMemberships < ActiveRecord::Migration[7.1] + def change + add_column :memberships, :role, :integer, null: false, default: 50 # Access::MAINTAINER + end +end diff --git a/db/schema.rb b/db/schema.rb index 749585bdfee..fe03e03761d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,42 +10,63 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_01_25_153619) do - +ActiveRecord::Schema[7.1].define(version: 2024_09_17_042436) do # These are extensions that must be enabled in order to support this database enable_extension "hstore" + enable_extension "pgcrypto" enable_extension "plpgsql" + create_table "admin_github_users", force: :cascade do |t| + t.string "login" + t.string "avatar_url" + t.string "github_id" + t.json "info_data" + t.string "oauth_token" + t.boolean "is_admin" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["github_id"], name: "index_admin_github_users_on_github_id", unique: true + end + + create_table "api_key_rubygem_scopes", force: :cascade do |t| + t.bigint "api_key_id", null: false + t.bigint "ownership_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["api_key_id"], name: "index_api_key_rubygem_scopes_on_api_key_id" + end + create_table "api_keys", force: :cascade do |t| - t.bigint "user_id", null: false t.string "name", null: false t.string "hashed_key", null: false - t.boolean "index_rubygems", default: false, null: false - t.boolean "push_rubygem", default: false, null: false - t.boolean "yank_rubygem", default: false, null: false - t.boolean "add_owner", default: false, null: false - t.boolean "remove_owner", default: false, null: false - t.boolean "access_webhooks", default: false, null: false - t.boolean "show_dashboard", default: false, null: false - t.datetime "last_accessed_at" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "last_accessed_at", precision: nil + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false + t.boolean "mfa", default: false, null: false + t.datetime "soft_deleted_at" + t.string "soft_deleted_rubygem_name" + t.datetime "expires_at", precision: nil + t.string "owner_type" + t.bigint "owner_id" + t.string "scopes", array: true t.index ["hashed_key"], name: "index_api_keys_on_hashed_key", unique: true - t.index ["user_id"], name: "index_api_keys_on_user_id" + t.index ["owner_type", "owner_id"], name: "index_api_keys_on_owner" + t.check_constraint "owner_id IS NOT NULL", name: "api_keys_owner_id_null" + t.check_constraint "owner_type IS NOT NULL", name: "api_keys_owner_type_null" + t.check_constraint "scopes IS NOT NULL", name: "api_keys_scopes_null" end - create_table "delayed_jobs", id: :serial, force: :cascade do |t| - t.integer "priority", default: 0 - t.integer "attempts", default: 0 - t.text "handler" - t.text "last_error" - t.datetime "run_at" - t.datetime "locked_at" - t.datetime "failed_at" - t.string "locked_by" - t.datetime "created_at" - t.datetime "updated_at" - t.string "queue" + create_table "audits", force: :cascade do |t| + t.string "auditable_type", null: false + t.bigint "auditable_id", null: false + t.bigint "admin_github_user_id", null: false + t.text "audited_changes" + t.string "comment" + t.string "action", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["admin_github_user_id"], name: "index_audits_on_admin_github_user_id" + t.index ["auditable_type", "auditable_id"], name: "index_audits_on_auditable" end create_table "deletions", id: :serial, force: :cascade do |t| @@ -53,15 +74,17 @@ t.string "rubygem" t.string "number" t.string "platform" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false + t.bigint "version_id" t.index ["user_id"], name: "index_deletions_on_user_id" + t.index ["version_id"], name: "index_deletions_on_version_id" end create_table "dependencies", id: :serial, force: :cascade do |t| t.string "requirements" - t.datetime "created_at" - t.datetime "updated_at" + t.datetime "created_at", precision: nil + t.datetime "updated_at", precision: nil t.integer "rubygem_id" t.integer "version_id" t.string "scope" @@ -71,6 +94,51 @@ t.index ["version_id"], name: "index_dependencies_on_version_id" end + create_table "events_organization_events", force: :cascade do |t| + t.string "tag", null: false + t.string "trace_id" + t.bigint "organization_id", null: false + t.bigint "ip_address_id" + t.bigint "geoip_info_id" + t.jsonb "additional" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["geoip_info_id"], name: "index_events_organization_events_on_geoip_info_id" + t.index ["ip_address_id"], name: "index_events_organization_events_on_ip_address_id" + t.index ["organization_id"], name: "index_events_organization_events_on_organization_id" + t.index ["tag"], name: "index_events_organization_events_on_tag" + end + + create_table "events_rubygem_events", force: :cascade do |t| + t.string "tag", null: false + t.string "trace_id" + t.bigint "rubygem_id", null: false + t.bigint "ip_address_id" + t.bigint "geoip_info_id" + t.jsonb "additional" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["geoip_info_id"], name: "index_events_rubygem_events_on_geoip_info_id" + t.index ["ip_address_id"], name: "index_events_rubygem_events_on_ip_address_id" + t.index ["rubygem_id"], name: "index_events_rubygem_events_on_rubygem_id" + t.index ["tag"], name: "index_events_rubygem_events_on_tag" + end + + create_table "events_user_events", force: :cascade do |t| + t.string "tag", null: false + t.string "trace_id" + t.bigint "user_id", null: false + t.bigint "ip_address_id" + t.bigint "geoip_info_id" + t.jsonb "additional" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["geoip_info_id"], name: "index_events_user_events_on_geoip_info_id" + t.index ["ip_address_id"], name: "index_events_user_events_on_ip_address_id" + t.index ["tag"], name: "index_events_user_events_on_tag" + t.index ["user_id"], name: "index_events_user_events_on_user_id" + end + create_table "gem_downloads", id: :serial, force: :cascade do |t| t.integer "rubygem_id", null: false t.integer "version_id", null: false @@ -80,11 +148,142 @@ t.index ["version_id", "rubygem_id", "count"], name: "index_gem_downloads_on_version_id_and_rubygem_id_and_count" end + create_table "gem_name_reservations", force: :cascade do |t| + t.string "name", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["name"], name: "index_gem_name_reservations_on_name", unique: true + end + create_table "gem_typo_exceptions", force: :cascade do |t| t.string "name" t.text "info" + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false + end + + create_table "geoip_infos", force: :cascade do |t| + t.string "continent_code", limit: 2 + t.string "country_code", limit: 2 + t.string "country_code3", limit: 3 + t.string "country_name" + t.string "region" + t.string "city" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["continent_code", "country_code", "country_code3", "country_name", "region", "city"], name: "index_geoip_infos_on_fields", unique: true + end + + create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.text "description" + t.jsonb "serialized_properties" + t.text "on_finish" + t.text "on_success" + t.text "on_discard" + t.text "callback_queue_name" + t.integer "callback_priority" + t.datetime "enqueued_at" + t.datetime "discarded_at" + t.datetime "finished_at" + end + + create_table "good_job_executions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "active_job_id", null: false + t.text "job_class" + t.text "queue_name" + t.jsonb "serialized_params" + t.datetime "scheduled_at" + t.datetime "finished_at" + t.text "error" + t.integer "error_event", limit: 2 + t.text "error_backtrace", array: true + t.uuid "process_id" + t.interval "duration" + t.index ["active_job_id", "created_at"], name: "index_good_job_executions_on_active_job_id_and_created_at" + t.index ["process_id", "created_at"], name: "index_good_job_executions_on_process_id_and_created_at" + end + + create_table "good_job_processes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.jsonb "state" + t.integer "lock_type", limit: 2 + end + + create_table "good_job_settings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.text "key" + t.jsonb "value" + t.index ["key"], name: "index_good_job_settings_on_key", unique: true + end + + create_table "good_jobs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.text "queue_name" + t.integer "priority" + t.jsonb "serialized_params" + t.datetime "scheduled_at" + t.datetime "performed_at" + t.datetime "finished_at" + t.text "error" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "active_job_id" + t.text "concurrency_key" + t.text "cron_key" + t.uuid "retried_good_job_id" + t.datetime "cron_at" + t.uuid "batch_id" + t.uuid "batch_callback_id" + t.boolean "is_discrete" + t.integer "executions_count" + t.text "job_class" + t.integer "error_event", limit: 2 + t.text "labels", array: true + t.uuid "locked_by_id" + t.datetime "locked_at" + t.index ["active_job_id", "created_at"], name: "index_good_jobs_on_active_job_id_and_created_at" + t.index ["batch_callback_id"], name: "index_good_jobs_on_batch_callback_id", where: "(batch_callback_id IS NOT NULL)" + t.index ["batch_id"], name: "index_good_jobs_on_batch_id", where: "(batch_id IS NOT NULL)" + t.index ["concurrency_key"], name: "index_good_jobs_on_concurrency_key_when_unfinished", where: "(finished_at IS NULL)" + t.index ["cron_key", "created_at"], name: "index_good_jobs_on_cron_key_and_created_at_cond", where: "(cron_key IS NOT NULL)" + t.index ["cron_key", "cron_at"], name: "index_good_jobs_on_cron_key_and_cron_at_cond", unique: true, where: "(cron_key IS NOT NULL)" + t.index ["finished_at"], name: "index_good_jobs_jobs_on_finished_at", where: "((retried_good_job_id IS NULL) AND (finished_at IS NOT NULL))" + t.index ["labels"], name: "index_good_jobs_on_labels", where: "(labels IS NOT NULL)", using: :gin + t.index ["locked_by_id"], name: "index_good_jobs_on_locked_by_id", where: "(locked_by_id IS NOT NULL)" + t.index ["priority", "created_at"], name: "index_good_job_jobs_for_candidate_lookup", where: "(finished_at IS NULL)" + t.index ["priority", "created_at"], name: "index_good_jobs_jobs_on_priority_created_at_when_unfinished", order: { priority: "DESC NULLS LAST" }, where: "(finished_at IS NULL)" + t.index ["priority", "scheduled_at"], name: "index_good_jobs_on_priority_scheduled_at_unfinished_unlocked", where: "((finished_at IS NULL) AND (locked_by_id IS NULL))" + t.index ["queue_name", "scheduled_at"], name: "index_good_jobs_on_queue_name_and_scheduled_at", where: "(finished_at IS NULL)" + t.index ["scheduled_at"], name: "index_good_jobs_on_scheduled_at", where: "(finished_at IS NULL)" + end + + create_table "ip_addresses", force: :cascade do |t| + t.inet "ip_address", null: false + t.text "hashed_ip_address", null: false + t.bigint "geoip_info_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["geoip_info_id"], name: "index_ip_addresses_on_geoip_info_id" + t.index ["hashed_ip_address"], name: "index_ip_addresses_on_hashed_ip_address", unique: true + t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true + end + + create_table "link_verifications", force: :cascade do |t| + t.string "linkable_type", null: false + t.bigint "linkable_id", null: false + t.string "uri", null: false + t.datetime "last_verified_at" + t.datetime "last_failure_at" + t.integer "failures_since_last_verification", default: 0 t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.index ["linkable_id", "linkable_type", "uri"], name: "index_link_verifications_on_linkable_and_uri" + t.index ["linkable_type", "linkable_id"], name: "index_link_verifications_on_linkable" end create_table "linksets", id: :serial, force: :cascade do |t| @@ -95,8 +294,8 @@ t.string "mail" t.string "code" t.string "bugs" - t.datetime "created_at" - t.datetime "updated_at" + t.datetime "created_at", precision: nil + t.datetime "updated_at", precision: nil t.index ["rubygem_id"], name: "index_linksets_on_rubygem_id" end @@ -105,33 +304,167 @@ t.string "directory" t.integer "backend", default: 0 t.string "status" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.integer "processed_count" t.index ["directory", "key"], name: "index_log_tickets_on_directory_and_key", unique: true end + create_table "maintenance_tasks_runs", force: :cascade do |t| + t.string "task_name", null: false + t.datetime "started_at", precision: nil + t.datetime "ended_at", precision: nil + t.float "time_running", default: 0.0, null: false + t.bigint "tick_count", default: 0, null: false + t.bigint "tick_total" + t.string "job_id" + t.string "cursor" + t.string "status", default: "enqueued", null: false + t.string "error_class" + t.string "error_message" + t.text "backtrace" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.text "arguments" + t.integer "lock_version", default: 0, null: false + t.text "metadata" + t.index ["task_name", "status", "created_at"], name: "index_maintenance_tasks_runs", order: { created_at: :desc } + end + + create_table "memberships", force: :cascade do |t| + t.bigint "user_id", null: false + t.bigint "organization_id", null: false + t.datetime "confirmed_at", precision: nil + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "role", default: 50, null: false + t.index ["organization_id"], name: "index_memberships_on_organization_id" + t.index ["user_id", "organization_id"], name: "index_memberships_on_user_id_and_organization_id", unique: true + t.index ["user_id"], name: "index_memberships_on_user_id" + end + + create_table "oidc_api_key_roles", force: :cascade do |t| + t.bigint "oidc_provider_id", null: false + t.bigint "user_id", null: false + t.jsonb "api_key_permissions", null: false + t.string "name", null: false + t.jsonb "access_policy", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "token", limit: 32, null: false + t.datetime "deleted_at" + t.index ["oidc_provider_id"], name: "index_oidc_api_key_roles_on_oidc_provider_id" + t.index ["token"], name: "index_oidc_api_key_roles_on_token", unique: true + t.index ["user_id"], name: "index_oidc_api_key_roles_on_user_id" + end + + create_table "oidc_id_tokens", force: :cascade do |t| + t.bigint "oidc_api_key_role_id", null: false + t.jsonb "jwt", null: false + t.bigint "api_key_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["api_key_id"], name: "index_oidc_id_tokens_on_api_key_id" + t.index ["oidc_api_key_role_id"], name: "index_oidc_id_tokens_on_oidc_api_key_role_id" + end + + create_table "oidc_pending_trusted_publishers", force: :cascade do |t| + t.string "rubygem_name" + t.bigint "user_id", null: false + t.string "trusted_publisher_type", null: false + t.bigint "trusted_publisher_id", null: false + t.datetime "expires_at", precision: nil, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["trusted_publisher_type", "trusted_publisher_id"], name: "index_oidc_pending_trusted_publishers_on_trusted_publisher" + t.index ["user_id"], name: "index_oidc_pending_trusted_publishers_on_user_id" + end + + create_table "oidc_providers", force: :cascade do |t| + t.text "issuer" + t.jsonb "configuration" + t.jsonb "jwks" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["issuer"], name: "index_oidc_providers_on_issuer", unique: true + end + + create_table "oidc_rubygem_trusted_publishers", force: :cascade do |t| + t.bigint "rubygem_id", null: false + t.string "trusted_publisher_type", null: false + t.bigint "trusted_publisher_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["rubygem_id", "trusted_publisher_id", "trusted_publisher_type"], name: "index_oidc_rubygem_trusted_publishers_unique", unique: true + t.index ["trusted_publisher_type", "trusted_publisher_id"], name: "index_oidc_rubygem_trusted_publishers_on_trusted_publisher" + end + + create_table "oidc_trusted_publisher_github_actions", force: :cascade do |t| + t.string "repository_owner", null: false + t.string "repository_name", null: false + t.string "repository_owner_id", null: false + t.string "workflow_filename", null: false + t.string "environment" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["repository_owner", "repository_name", "repository_owner_id", "workflow_filename", "environment"], name: "index_oidc_trusted_publisher_github_actions_claims", unique: true + end + + create_table "organizations", force: :cascade do |t| + t.string "handle", limit: 40 + t.string "name", limit: 255 + t.datetime "deleted_at", precision: nil + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index "lower((handle)::text)", name: "index_organizations_on_lower_handle", unique: true + end + + create_table "ownership_calls", force: :cascade do |t| + t.bigint "rubygem_id" + t.bigint "user_id" + t.text "note" + t.boolean "status", default: true, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["rubygem_id"], name: "index_ownership_calls_on_rubygem_id" + t.index ["user_id"], name: "index_ownership_calls_on_user_id" + end + + create_table "ownership_requests", force: :cascade do |t| + t.bigint "rubygem_id" + t.bigint "ownership_call_id" + t.bigint "user_id" + t.text "note" + t.integer "status", limit: 2, default: 0, null: false + t.integer "approver_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["ownership_call_id"], name: "index_ownership_requests_on_ownership_call_id" + t.index ["rubygem_id"], name: "index_ownership_requests_on_rubygem_id" + t.index ["user_id"], name: "index_ownership_requests_on_user_id" + end + create_table "ownerships", id: :serial, force: :cascade do |t| t.integer "rubygem_id" t.integer "user_id" t.string "token" - t.datetime "created_at" - t.datetime "updated_at" + t.datetime "created_at", precision: nil + t.datetime "updated_at", precision: nil t.boolean "push_notifier", default: true, null: false - t.datetime "confirmed_at" - t.datetime "token_expires_at" + t.datetime "confirmed_at", precision: nil + t.datetime "token_expires_at", precision: nil t.boolean "owner_notifier", default: true, null: false t.integer "authorizer_id" + t.boolean "ownership_request_notifier", default: true, null: false + t.integer "role", default: 70, null: false t.index ["rubygem_id"], name: "index_ownerships_on_rubygem_id" t.index ["user_id", "rubygem_id"], name: "index_ownerships_on_user_id_and_rubygem_id", unique: true - t.index ["user_id"], name: "index_ownerships_on_user_id" end create_table "rubygems", id: :serial, force: :cascade do |t| t.string "name" - t.datetime "created_at" - t.datetime "updated_at" - t.string "slug" + t.datetime "created_at", precision: nil + t.datetime "updated_at", precision: nil t.boolean "indexed", default: false, null: false t.index "regexp_replace(upper((name)::text), '[_-]'::text, ''::text, 'g'::text)", name: "dashunderscore_typos_idx" t.index "upper((name)::text) varchar_pattern_ops", name: "index_rubygems_upcase" @@ -143,11 +476,11 @@ t.string "sendgrid_id", null: false t.string "email" t.string "event_type" - t.datetime "occurred_at" + t.datetime "occurred_at", precision: nil t.jsonb "payload", null: false t.string "status", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.index ["email"], name: "index_sendgrid_events_on_email" t.index ["sendgrid_id"], name: "index_sendgrid_events_on_sendgrid_id", unique: true end @@ -155,8 +488,8 @@ create_table "subscriptions", id: :serial, force: :cascade do |t| t.integer "rubygem_id" t.integer "user_id" - t.datetime "created_at" - t.datetime "updated_at" + t.datetime "created_at", precision: nil + t.datetime "updated_at", precision: nil t.index ["rubygem_id"], name: "index_subscriptions_on_rubygem_id" t.index ["user_id"], name: "index_subscriptions_on_user_id" end @@ -166,29 +499,36 @@ t.string "encrypted_password", limit: 128 t.string "salt", limit: 128 t.string "token", limit: 128 - t.datetime "token_expires_at" + t.datetime "token_expires_at", precision: nil t.boolean "email_confirmed", default: false, null: false t.string "api_key" t.string "confirmation_token", limit: 128 t.string "remember_token", limit: 128 - t.datetime "created_at" - t.datetime "updated_at" + t.datetime "created_at", precision: nil + t.datetime "updated_at", precision: nil t.boolean "email_reset" t.string "handle" - t.boolean "hide_email" + t.boolean "hide_email", default: true t.string "twitter_username" t.string "unconfirmed_email" - t.datetime "remember_token_expires_at" - t.string "mfa_seed" + t.datetime "remember_token_expires_at", precision: nil t.integer "mfa_level", default: 0 - t.string "mfa_recovery_codes", default: [], array: true t.integer "mail_fails", default: 0 + t.string "blocked_email" + t.string "webauthn_id" + t.string "full_name" + t.string "totp_seed" + t.string "mfa_hashed_recovery_codes", default: [], array: true + t.boolean "public_email", default: false, null: false + t.datetime "deleted_at" + t.index "lower((email)::text) varchar_pattern_ops", name: "index_users_on_lower_email" t.index ["email"], name: "index_users_on_email" t.index ["handle"], name: "index_users_on_handle" t.index ["id", "confirmation_token"], name: "index_users_on_id_and_confirmation_token" t.index ["id", "token"], name: "index_users_on_id_and_token" t.index ["remember_token"], name: "index_users_on_remember_token" t.index ["token"], name: "index_users_on_token" + t.index ["webauthn_id"], name: "index_users_on_webauthn_id", unique: true end create_table "versions", id: :serial, force: :cascade do |t| @@ -196,11 +536,11 @@ t.text "description" t.string "number" t.integer "rubygem_id" - t.datetime "built_at" - t.datetime "updated_at" + t.datetime "built_at", precision: nil + t.datetime "updated_at", precision: nil t.text "summary" t.string "platform" - t.datetime "created_at" + t.datetime "created_at", precision: nil t.boolean "indexed", default: true t.boolean "prerelease" t.integer "position" @@ -212,35 +552,102 @@ t.string "required_ruby_version" t.string "sha256" t.hstore "metadata", default: {}, null: false - t.datetime "yanked_at" + t.datetime "yanked_at", precision: nil t.string "required_rubygems_version", limit: 255 t.string "info_checksum" t.string "yanked_info_checksum" t.bigint "pusher_id" + t.text "cert_chain" t.string "canonical_number" + t.bigint "pusher_api_key_id" + t.string "gem_platform" + t.string "gem_full_name" + t.string "spec_sha256", limit: 44 t.index "lower((full_name)::text)", name: "index_versions_on_lower_full_name" + t.index "lower((gem_full_name)::text)", name: "index_versions_on_lower_gem_full_name" t.index ["built_at"], name: "index_versions_on_built_at" + t.index ["canonical_number", "rubygem_id", "platform"], name: "index_versions_on_canonical_number_and_rubygem_id_and_platform", unique: true t.index ["created_at"], name: "index_versions_on_created_at" t.index ["full_name"], name: "index_versions_on_full_name" t.index ["indexed", "yanked_at"], name: "index_versions_on_indexed_and_yanked_at" - t.index ["indexed"], name: "index_versions_on_indexed" t.index ["number"], name: "index_versions_on_number" - t.index ["position"], name: "index_versions_on_position" + t.index ["position", "rubygem_id"], name: "index_versions_on_position_and_rubygem_id" t.index ["prerelease"], name: "index_versions_on_prerelease" + t.index ["pusher_api_key_id"], name: "index_versions_on_pusher_api_key_id" t.index ["pusher_id"], name: "index_versions_on_pusher_id" t.index ["rubygem_id", "number", "platform"], name: "index_versions_on_rubygem_id_and_number_and_platform", unique: true - t.index ["rubygem_id"], name: "index_versions_on_rubygem_id" end create_table "web_hooks", id: :serial, force: :cascade do |t| t.integer "user_id" t.string "url" t.integer "failure_count", default: 0 - t.datetime "created_at" - t.datetime "updated_at" + t.datetime "created_at", precision: nil + t.datetime "updated_at", precision: nil t.integer "rubygem_id" + t.text "disabled_reason" + t.datetime "disabled_at", precision: nil + t.datetime "last_success", precision: nil + t.datetime "last_failure", precision: nil + t.integer "successes_since_last_failure", default: 0 + t.integer "failures_since_last_success", default: 0 t.index ["user_id", "rubygem_id"], name: "index_web_hooks_on_user_id_and_rubygem_id" end - add_foreign_key "api_keys", "users" + create_table "webauthn_credentials", force: :cascade do |t| + t.bigint "user_id", null: false + t.string "external_id", null: false + t.string "public_key", null: false + t.string "nickname", null: false + t.bigint "sign_count", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["external_id"], name: "index_webauthn_credentials_on_external_id", unique: true + t.index ["user_id"], name: "index_webauthn_credentials_on_user_id" + end + + create_table "webauthn_verifications", force: :cascade do |t| + t.string "path_token", limit: 128 + t.datetime "path_token_expires_at" + t.string "otp" + t.datetime "otp_expires_at" + t.bigint "user_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_webauthn_verifications_on_user_id", unique: true + end + + add_foreign_key "api_key_rubygem_scopes", "api_keys", name: "api_key_rubygem_scopes_api_key_id_fk" + add_foreign_key "audits", "admin_github_users", name: "audits_admin_github_user_id_fk" + add_foreign_key "events_organization_events", "geoip_infos" + add_foreign_key "events_organization_events", "ip_addresses" + add_foreign_key "events_organization_events", "organizations" + add_foreign_key "events_rubygem_events", "geoip_infos" + add_foreign_key "events_rubygem_events", "ip_addresses" + add_foreign_key "events_rubygem_events", "rubygems" + add_foreign_key "events_user_events", "geoip_infos" + add_foreign_key "events_user_events", "ip_addresses" + add_foreign_key "events_user_events", "users" + add_foreign_key "ip_addresses", "geoip_infos" + add_foreign_key "linksets", "rubygems", name: "linksets_rubygem_id_fk" + add_foreign_key "memberships", "organizations" + add_foreign_key "memberships", "users" + add_foreign_key "oidc_api_key_roles", "oidc_providers" + add_foreign_key "oidc_api_key_roles", "users" + add_foreign_key "oidc_id_tokens", "api_keys" + add_foreign_key "oidc_id_tokens", "oidc_api_key_roles" + add_foreign_key "oidc_pending_trusted_publishers", "users" + add_foreign_key "oidc_rubygem_trusted_publishers", "rubygems" + add_foreign_key "ownership_calls", "rubygems", name: "ownership_calls_rubygem_id_fk" + add_foreign_key "ownership_calls", "users", name: "ownership_calls_user_id_fk" + add_foreign_key "ownership_requests", "ownership_calls", name: "ownership_requests_ownership_call_id_fk" + add_foreign_key "ownership_requests", "rubygems", name: "ownership_requests_rubygem_id_fk" + add_foreign_key "ownership_requests", "users", column: "approver_id", name: "ownership_requests_approver_id_fk" + add_foreign_key "ownership_requests", "users", name: "ownership_requests_user_id_fk" + add_foreign_key "ownerships", "users", on_delete: :cascade + add_foreign_key "versions", "api_keys", column: "pusher_api_key_id" + add_foreign_key "versions", "rubygems", name: "versions_rubygem_id_fk" + add_foreign_key "web_hooks", "users", name: "web_hooks_user_id_fk" + add_foreign_key "webauthn_credentials", "users" + add_foreign_key "webauthn_verifications", "users" end diff --git a/db/seeds.rb b/db/seeds.rb new file mode 100644 index 00000000000..2b84f60671e --- /dev/null +++ b/db/seeds.rb @@ -0,0 +1,321 @@ +password = "super-secret-password" + +author = User.create_with( + handle: "gem-author", + password: password, + email_confirmed: true, + webauthn_id: "a1TLW3o1W18mTuDBfDALHhL2tZ1_E-2B03Fqsdu8Rv05V4tSsRzepe-L7Uprg356dw1tktXXcTI9TIRaK4gM-A" +).find_or_create_by!(email: "gem-author@example.com") + +maintainer = User.create_with( + handle: "gem-maintainer", + password: password, + email_confirmed: true +).find_or_create_by!(email: "gem-maintainer@example.com") + +user = User.create_with( + handle: "gem-user", + password: password, + email_confirmed: true +).find_or_create_by!(email: "gem-user@example.com") + +requester = User.create_with( + handle: "gem-requester", + password: password, + email_confirmed: true +).find_or_create_by!(email: "gem-requester@example.com") + +User.create_with( + email_confirmed: true, + password: +).find_or_create_by!(email: "security@rubygems.org") + +rubygem0 = Rubygem.find_or_create_by!( + name: "rubygem0" +) do |rubygem| + rubygem.ownerships.new(user: author, authorizer: author).confirm! +end + +rubygem1 = Rubygem.find_or_create_by!( + name: "rubygem1" +) do |rubygem| + rubygem.ownerships.new(user: author, authorizer: author).confirm! + rubygem.ownerships.new(user: maintainer, authorizer: author).confirm! +end + +rubygem_requestable = Rubygem.find_or_create_by!( + name: "rubygem_requestable" +) do |rubygem| + rubygem.ownerships.new(user: author, authorizer: author).confirm! +end + +rubygem_requestable.ownership_calls.create_with( + note: "closed ownership call note!", + status: :closed +).find_or_create_by!(user: author) +rubygem_requestable.ownership_calls.create_with( + note: "open ownership call note!" +).find_or_create_by!(user: author) +rubygem_requestable.ownership_requests.create_with( + note: "open ownership request" +).find_or_create_by!(ownership_call: rubygem_requestable.ownership_call, user: requester) + +Version.create_with( + indexed: true, + pusher: author, + sha256: Digest::SHA256.base64digest("abc123"), + info_checksum: Digest::MD5.base64digest("abc123") +).find_or_create_by!(rubygem: rubygem0, number: "0.0.1", platform: "ruby", gem_platform: "ruby") do |version| + author.deletions.find_or_create_by!(version: version) +end +Version.create_with( + indexed: true, + pusher: author, + metadata: { + homepage_uri: "https://example.com/rubygem0/home", + source_code_uri: "https://github.com/example/#{rubygem0.name}" + }, + sha256: Digest::SHA2.base64digest("rubygem0-1.0.0.gem") +).find_or_create_by!(rubygem: rubygem0, number: "1.0.0", platform: "ruby", gem_platform: "ruby") +Version.create_with( + indexed: true, + sha256: Digest::SHA2.base64digest("rubygem0-1.0.0-x86_64-darwin.gem") +).find_or_create_by!(rubygem: rubygem0, number: "1.0.0", platform: "x86_64-darwin", gem_platform: Gem::Platform.new("x86_64-darwin").to_s) + +Version.create_with( + indexed: true, + pusher: author, + sha256: Digest::SHA2.base64digest("rubygem1-1.0.0.pre.1.gem") +).find_or_create_by!(rubygem: rubygem1, number: "1.0.0.pre.1", platform: "ruby", gem_platform: "ruby") +Version.create_with( + indexed: true, + pusher: maintainer, + dependencies: [Dependency.new(gem_dependency: Gem::Dependency.new("rubygem0", "~> 1.0.0"))], + sha256: Digest::SHA2.base64digest("rubygem1-1.1.0.pre.2.gem") +).find_or_create_by!(rubygem: rubygem1, number: "1.1.0.pre.2", platform: "ruby", gem_platform: "ruby") +Version.create_with( + indexed: false, + pusher: author, + yanked_at: Time.utc(2020, 3, 3), + sha256: Digest::SHA2.base64digest("rubygem_requestable-1.0.0.gem") +).find_or_create_by!(rubygem: rubygem_requestable, number: "1.0.0", platform: "ruby", gem_platform: "ruby") + +user.web_hooks.find_or_create_by!(url: "https://example.com/rubygem0", rubygem: rubygem0) +user.web_hooks.find_or_create_by!(url: "http://example.com/all", rubygem: nil) + +author.api_keys.find_or_create_by!(hashed_key: "securehashedkey", name: "api key", scopes: %i[push_rubygem]) + +Admin::GitHubUser.create_with( + is_admin: true, + oauth_token: "fake", + info_data: { + viewer: { + name: "Rad Admin", + login: "rad_admin", + email: "rad_admin@rubygems.team", + avatarUrl: "/favicon.ico", + organization: { + login: "rubygems", + name: "RubyGems", + viewerIsAMember: true, + teams: { + edges: [ + { + node: { + name: "Infrastructure", + slug: "infrastructure" + } + }, + { + node: { + name: "Maintainers", + slug: "maintainers" + } + }, + { + node: { + name: "Monitoring", + slug: "monitoring" + } + }, + { + node: { + name: "RubyGems.org", + slug: "rubygems-org" + } + }, + { + node: { + name: "Rubygems.org Deployers", + slug: "rubygems-org-deployers" + } + }, + { + node: { + name: "Security", + slug: "security" + } + } + ] + } + } + } + } +).find_or_create_by!(github_id: "FAKE-rad_admin") + +Admin::GitHubUser.create_with( + is_admin: false, + info_data: { + viewer: { + name: "Not An Admin", + login: "not_an_admin", + email: "not_an_admin@rubygems.team", + avatarUrl: "/favicon.ico", + organization: nil + } + } +).find_or_create_by!(github_id: "FAKE-not_an_admin") + +github_oidc_provider = OIDC::Provider + .create_with( + configuration: { + issuer: OIDC::Provider::GITHUB_ACTIONS_ISSUER, + jwks_uri: "#{OIDC::Provider::GITHUB_ACTIONS_ISSUER}/.well-known/jwks", + subject_types_supported: %w[public pairwise], + response_types_supported: ["id_token"], + claims_supported: %w[sub aud exp iat iss jti nbf ref repository repository_id repository_owner repository_owner_id + run_id run_number run_attempt actor actor_id workflow workflow_ref workflow_sha head_ref + base_ref event_name ref_type environment environment_node_id job_workflow_ref + job_workflow_sha repository_visibility runner_environment], + id_token_signing_alg_values_supported: ["RS256"], + scopes_supported: ["openid"] + } + ).find_or_create_by!(issuer: OIDC::Provider::GITHUB_ACTIONS_ISSUER) + +author_oidc_api_key_role = author.oidc_api_key_roles.create_with( + api_key_permissions: { + gems: ["rubygem0"], + scopes: ["push_rubygem"], + valid_for: "PT20M" + }, + access_policy: { + statements: [ + effect: "allow", + principal: { + oidc: "https://token.actions.githubusercontent.com" + }, + conditions: [{ + operator: "string_equals", + claim: "repository", + value: "rubygems/rubygem0" + }] + ] + } +).find_or_create_by!( + name: "push-rubygem-1", + provider: github_oidc_provider +) + +author_oidc_api_key_role.user.api_keys.create_with( + hashed_key: "expiredhashedkey", + ownership: rubygem0.ownerships.find_by!(user: author), + scopes: %i[push_rubygem] +).find_or_create_by!( + name: "push-rubygem-1-expired" +).tap do |api_key| + OIDC::IdToken.find_or_create_by!( + api_key:, + jwt: { claims: { jti: "expired" }, header: {} }, + api_key_role: author_oidc_api_key_role + ) + api_key.touch(:expires_at, time: "2020-01-01T00:00:00Z") +end + +author_oidc_api_key_role.user.api_keys.create_with( + hashed_key: "unexpiredhashedkey", + ownership: rubygem0.ownerships.find_by!(user: author), + scopes: %i[push_rubygem], + expires_at: "2120-01-01T00:00:00Z" +).find_or_create_by!( + name: "push-rubygem-1-unexpired" +).tap do |api_key| + OIDC::IdToken.find_or_create_by!( + api_key:, + jwt: { claims: { jti: "unexpired" }, header: {} }, + api_key_role: author_oidc_api_key_role + ) +end + +author.api_keys.find_or_create_by!( + hashed_key: "unexpiredmanualhashedkey", + name: "Manual", + scopes: %i[push_rubygem] +) + +SendgridEvent.create_with( + event_type: "delivered", + email: author.email, + occurred_at: Time.zone.now, + payload: { + ip: "192.168.1.1", + tls: 1, + email: author.email, + event: "delivered", + sg_event_id: "sg_event_id_1" + }, + status: :processed +).find_or_create_by!(sendgrid_id: "sendgrid_id_1") + +rubygem0.link_verifications.create_with( + last_verified_at: 10.years.since +).find_or_create_by!(uri: "https://example.com/rubygem0/home") +rubygem0.link_verifications.create_with( + last_verified_at: 10.years.since +).find_or_create_by!(uri: "https://example.com/rubygem0/code") + +trusted_publisher = OIDC::TrustedPublisher::GitHubAction.find_or_create_by!( + repository_owner: "example", + repository_name: "rubygem0", + repository_owner_id: "1234567890", + workflow_filename: "push_gem.yml", + environment: nil +) +trusted_publisher.rubygem_trusted_publishers.find_or_create_by!(rubygem: rubygem0).trusted_publisher.api_keys.find_or_create_by!( + name: "GitHub Actions something", + hashed_key: "securehashedkey-tp", + scopes: %i[push_rubygem] +).pushed_versions.create_with(indexed: true, sha256: Digest::SHA2.base64digest("rubygem0-0.1.0.gem")).find_or_create_by!( + rubygem: rubygem0, number: "0.1.0", platform: "ruby", gem_platform: "ruby" +) +trusted_publisher.rubygem_trusted_publishers.find_or_create_by!(rubygem: rubygem1) + +OIDC::TrustedPublisher::GitHubAction.find_or_create_by!( + repository_owner: "example", + repository_name: "rubygem0", + repository_owner_id: "1234567890", + workflow_filename: "push_gem2.yml", + environment: "deploy" +).rubygem_trusted_publishers.find_or_create_by!(rubygem: rubygem0) + +author.oidc_pending_trusted_publishers.create_with( + expires_at: 100.years.from_now +).find_or_create_by!( + trusted_publisher: trusted_publisher, + rubygem_name: "pending-trusted-publisher-rubygem" +) + +author.webauthn_credentials.create_with(nickname: "segiddins development") + .find_or_create_by!( + external_id: "QdfU3FxkjNpPqfjC4uTuNA", + public_key: "pQECAyYgASFYIKMIHolehDjslWQ6oOVP1-R8OR6LXEBdDfqxhjgtiiDEIlgg1RgUq_AJFT-cSMo-xP_9XxGIbBsQDEj8253QPwc8-88" + ) + +IpAddress.find_or_create_by!(ip_address: "127.0.0.1") + +puts <<~MESSAGE # rubocop:disable Rails/Output + Four users were created, you can login with following combinations: + - email: #{author.email}, password: #{password} -> gem author owning few example gems + - email: #{maintainer.email}, password: #{password} -> gem maintainer having push access to one author's example gem + - email: #{user.email}, password: #{password} -> user with no gems + - email: #{requester.email}, password: #{password} -> user with an ownership request +MESSAGE diff --git a/doc/erd.svg b/doc/erd.svg deleted file mode 100644 index 0d331b78a0f..00000000000 --- a/doc/erd.svg +++ /dev/null @@ -1,325 +0,0 @@ - - - - - - -Gemcutter - -RubyGems.org domain model - - -m_Announcement - -Announcement - -body -text - - - -m_Delayed::Backend::ActiveRecord::Job - -Delayed::Backend::ActiveRecord::Job - -attempts -integer -failed_at -datetime -handler -text -last_error -text -locked_at -datetime -locked_by -string -priority -integer -queue -string -run_at -datetime - - - -m_Deletion - -Deletion - -number -string ∗ -platform -string -rubygem -string ∗ - - - -m_Dependency - -Dependency - -requirements -string ∗ -scope -string -unresolved_name -string - - - -m_GemDownload - -GemDownload - -count -integer (8) - - - -m_Linkset - -Linkset - -bugs -string -code -string -docs -string -home -string -mail -string -wiki -string - - - -m_LogTicket - -LogTicket - -backend -integer -directory -string -key -string -processed_count -integer -status -string - - - -m_Ownership - -Ownership - -token -string - - - -m_Rubygem - -Rubygem - -name -string ∗ U -slug -string - - - -m_Rubygem->m_Dependency - - - - - -m_Rubygem->m_GemDownload - - - - -m_Rubygem->m_Linkset - - - - -m_Rubygem->m_Ownership - - - - - -m_Subscription - -Subscription - - - -m_Rubygem->m_Subscription - - - - - -m_User - -User - -api_key -string -confirmation_token -string (128) -email -string ∗ U -email_confirmed -boolean ∗ -email_reset -boolean -encrypted_password -string (128) -handle -string U -hide_email -boolean -last_otp_at -datetime -mfa_level -integer -mfa_recovery_codes -string -mfa_seed -string -remember_token -string (128) -remember_token_expires_at -datetime -salt -string (128) -token -string (128) -token_expires_at -datetime -twitter_username -string -unconfirmed_email -string - - - -m_Version - -Version - -authors -text -built_at -datetime -description -text -full_name -string ∗ U -indexed -boolean -info_checksum -string -latest -boolean -licenses -string -metadata -hstore ∗ -number -string -platform -string -position -integer -prerelease -boolean -required_ruby_version -string -required_rubygems_version -string -requirements -text -sha256 -string -size -integer -summary -text -yanked_at -datetime -yanked_info_checksum -string - - - -m_Rubygem->m_Version - - - - - -m_WebHook - -WebHook - -failure_count -integer -url -string - - - -m_Rubygem->m_WebHook - - - - - -m_User->m_Deletion - - - - - -m_User->m_Ownership - - - - - -m_User->m_Subscription - - - - - -m_User->m_WebHook - - - - - -m_Version->m_Dependency - - - - - -m_Version->m_GemDownload - - - - diff --git a/docker-compose.yml b/docker-compose.yml index 57446c7afa9..f2c7e57bb24 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,20 +1,59 @@ -version: '3' services: db: - image: postgres:9.6 + image: index.docker.io/library/postgres@sha256:e0892b968fb80d181a96f18bfef0a8a1693c2430fb2bc7392e65a53057eaa303 # 13.14 ports: - "5432:5432" environment: - POSTGRES_HOST_AUTH_METHOD=trust + downloads-db: + image: index.docker.io/timescale/timescaledb@sha256:2e3a19fa4624addcb2bb8d37dfe2fee9e12597537b057a742c68aa226ed77da5 # 2.15.1-pg16 + ports: + - "5434:5432" + environment: + - POSTGRES_HOST_AUTH_METHOD=trust cache: - image: memcached + image: index.docker.io/library/memcached@sha256:f4504742a8fb03c3ac0cd172e1c1d2277117629f8d21d52f78307121ddc3de5f # 1.4.39 ports: - "11211:11211" search: - image: elasticsearch:5.6.16 + image: index.docker.io/opensearchproject/opensearch@sha256:2e954ff0e8c9d0f4868b4818150b3aecc92fbb0cc4a24d00dace38ada227291d # 2.13.0 environment: - - http.host=0.0.0.0 - - transport.host=127.0.0.1 - - xpack.security.enabled=false + - discovery.type=single-node + - DISABLE_SECURITY_PLUGIN=true + - "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m" ports: - "9200:9200" + healthcheck: + test: + [ + "CMD", + "curl", + "--fail", + "--silent", + "http://localhost:9200/_cluster/health?wait_for_status=green&timeout=5s", + ] + interval: 5s + timeout: 5s + retries: 6 + search-console: + image: index.docker.io/opensearchproject/opensearch-dashboards@sha256:d8f4442da4d0cb44865a5eab01c9eb9f00769e2d5f053d21e3ff3c64a50fc6ec # 2.13.0 + ports: + - "5601:5601" + environment: + - 'OPENSEARCH_HOSTS=["http://search:9200"]' + - "DISABLE_SECURITY_DASHBOARDS_PLUGIN=true" + - "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m" + healthcheck: + test: + [ + "CMD-SHELL", + "curl --fail --silent http://localhost:5601/api/status || exit 1", + ] + interval: 5s + timeout: 3s + depends_on: + search: + condition: service_healthy + toxiproxy: + image: ghcr.io/shopify/toxiproxy@sha256:927c797a2115a193ae3a527e5a36782b938419904ac6706ca0efa029ebea58cb # 2.5.0 + network_mode: "host" diff --git a/lib/access.rb b/lib/access.rb new file mode 100644 index 00000000000..12316b95d03 --- /dev/null +++ b/lib/access.rb @@ -0,0 +1,27 @@ +module Access + MAINTAINER = 50 + OWNER = 70 + ADMIN = 90 + + DEFAULT_ROLE = "owner".freeze + + ROLES = { + "maintainer" => MAINTAINER, + "owner" => OWNER, + "admin" => ADMIN + }.with_indifferent_access.freeze + + def self.flag_for_role(role) + ROLES.fetch(role) + end + + def self.with_minimum_role(role) + Range.new(flag_for_role(role), nil) + end + + def self.role_for_flag(flag) + ROLES.key(flag)&.inquiry.tap do |role| + raise ArgumentError, "Unknown role flag: #{flag}" if role.blank? + end + end +end diff --git a/lib/admin/authorization_client.rb b/lib/admin/authorization_client.rb new file mode 100644 index 00000000000..6a95c709438 --- /dev/null +++ b/lib/admin/authorization_client.rb @@ -0,0 +1,42 @@ +# This class is the same as the default pundit authorization client. +# It just adds the admin scope automatically so that Avo pundit policies can be kept separate. +class Admin::AuthorizationClient < Avo::Services::AuthorizationClients::PunditClient + def authorize(user, record, action, policy_class: nil) + # After https://github.com/avo-hq/avo/pull/2827 lands, we can hopefully remove this hack + policy_class ||= Admin::GitHubUserPolicy if record == Admin::GitHubUser + super(user, [:admin, record], action, policy_class: policy_class) + end + + def policy(user, record) + super(user, [:admin, record]) + end + + def policy!(user, record) + super(user, [:admin, record]) + end + + def apply_policy(user, model, policy_class: nil) + # Try and figure out the scope from a given policy or auto-detected one + scope_from_policy_class = scope_for_policy_class(policy_class) + + # If we discover one use it. + # Else fallback to pundit. + if scope_from_policy_class.present? + scope_from_policy_class.new(user, model).resolve + else + Pundit.policy_scope!(user, [:admin, model]) + end + rescue Pundit::NotDefinedError => e + raise Avo::NoPolicyError, e.message + end + + private + + # Fetches the scope for a given policy + def scope_for_policy_class(policy_class = nil) + return if policy_class.blank? + + return unless policy_class.present? && defined?(Admin.const_get(policy_class.to_s)&.const_get("Scope")) + policy_class::Scope + end +end diff --git a/lib/app_revision.rb b/lib/app_revision.rb index 9d3c1cf41ac..0cd497d8c15 100644 --- a/lib/app_revision.rb +++ b/lib/app_revision.rb @@ -7,11 +7,19 @@ def self.revision_or_fallback begin revision_file.read rescue Errno::ENOENT - `git rev-parse HEAD` + begin + git_revision + rescue Errno::ENOENT + "UNKNOWN" + end end.strip end def self.revision_file Rails.root.join("REVISION") end + + def self.git_revision + `git rev-parse HEAD` + end end diff --git a/lib/certificate_chain_serializer.rb b/lib/certificate_chain_serializer.rb new file mode 100644 index 00000000000..954d7622d07 --- /dev/null +++ b/lib/certificate_chain_serializer.rb @@ -0,0 +1,18 @@ +class CertificateChainSerializer + PATTERN = /-----BEGIN CERTIFICATE-----(?:.|\n)+?-----END CERTIFICATE-----/ + + def self.load(chain) + return [] unless chain + chain.scan(PATTERN).map! do |cert| + OpenSSL::X509::Certificate.new(cert) + end + end + + def self.dump(chain) + return if chain.blank? + normalised = chain.map do |cert| + cert.respond_to?(:to_pem) ? cert : OpenSSL::X509::Certificate.new(cert) + end + normalised.map!(&:to_pem).join + end +end diff --git a/lib/constraints/admin.rb b/lib/constraints/admin.rb new file mode 100644 index 00000000000..dd9a210b0c8 --- /dev/null +++ b/lib/constraints/admin.rb @@ -0,0 +1,27 @@ +class Constraints::Admin + def self.matches?(request) + Matcher.new(request).admin_user&.valid? + end + + class Matcher + include GitHubOAuthable + + def initialize(request) + @request = ActionDispatch::Request.new(request.env) + @cookies = request.cookie_jar + end + attr_reader :request, :cookies + + def admin_user + request.fetch_header(admin_user_request_header) { nil } + end + end + + class RubygemsOrgAdmin + def self.matches?(request) + admin_user = Matcher.new(request).admin_user + return false unless admin_user&.valid? + admin_user.team_member?("rubygems-org") + end + end +end diff --git a/lib/cops/safe_navigation_cop.rb b/lib/cops/safe_navigation_cop.rb new file mode 100644 index 00000000000..85e04f8d43f --- /dev/null +++ b/lib/cops/safe_navigation_cop.rb @@ -0,0 +1,16 @@ +class RuboCop::Cop::Style::CustomSafeNavigationCop < RuboCop::Cop::Cop + MSG = "Use ruby safe navigation opetator (&.) instead of try".freeze + + def_node_matcher :try_call?, <<-PATTERN + (send (...) :try (...)) + PATTERN + + def_node_matcher :try_bang_call?, <<-PATTERN + (send (...) :try! (...)) + PATTERN + + def on_send(node) + return unless try_call?(node) || try_bang_call?(node) + add_offense(node) + end +end diff --git a/lib/elastic_searcher.rb b/lib/elastic_searcher.rb index 51b93a52993..8436e740ccb 100644 --- a/lib/elastic_searcher.rb +++ b/lib/elastic_searcher.rb @@ -1,26 +1,59 @@ class ElasticSearcher - def initialize(query, page: 1, api: false) + CONNECTION_ERRORS = [ + Faraday::ConnectionFailed, + Faraday::TimeoutError, + Searchkick::Error, + OpenSearch::Transport::Transport::Error, + Errno::ECONNRESET + ].freeze + + SearchNotAvailableError = Class.new(StandardError) + InvalidQueryError = Class.new(StandardError) + + def initialize(query, page: 1) @query = query @page = page - @api = api end def search - result = Rubygem.__elasticsearch__.search(search_definition).page(@page) + result = Rubygem.searchkick_search( + body: search_definition.to_hash, + page: @page, + per_page: Kaminari.config.default_per_page, + load: false + ) result.response # ES query is triggered here to allow fallback. avoids lazy loading done in the view - @api ? result.map(&:_source) : [nil, result] - rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Elasticsearch::Transport::Transport::Error => e - result = Rubygem.legacy_search(@query).page(@page) - @api ? result : [error_msg(e), result] + [nil, result] + rescue StandardError => e + [error_msg(e), nil] + end + + def api_search + result = Rubygem.searchkick_search(body: search_definition(for_api: true).to_hash, page: @page, per_page: Kaminari.config.default_per_page, +load: false) + result.response["hits"]["hits"].pluck("_source") + rescue Searchkick::InvalidQueryError => e + raise InvalidQueryError, error_msg(e) + rescue *CONNECTION_ERRORS => e + raise SearchNotAvailableError, error_msg(e) + end + + def suggestions + result = Rubygem.searchkick_search(body: suggestions_definition.to_hash, page: @page, per_page: Kaminari.config.default_per_page, load: false) + result = result.response["suggest"]["completion_suggestion"][0]["options"] + result.map { |gem| gem["_source"]["name"] } + rescue *CONNECTION_ERRORS => e + Rails.error.report(e, handled: true) + Array(nil) end private - def search_definition # rubocop:disable Metrics/MethodLength + def search_definition(for_api: false) # rubocop:disable Metrics/MethodLength query_str = @query - source_array = @api ? api_source : ui_source + source_array = for_api ? api_source : ui_source - Elasticsearch::DSL::Search.search do + OpenSearch::DSL::Search.search do query do function_score do query do @@ -73,12 +106,21 @@ def search_definition # rubocop:disable Metrics/MethodLength end end + def suggestions_definition + query_str = @query + + OpenSearch::DSL::Search.search do + suggest :completion_suggestion, prefix: query_str, completion: { field: "suggest", contexts: { yanked: false }, size: 30 } + source "name" + end + end + def error_msg(error) - if error.is_a? Elasticsearch::Transport::Transport::Errors::BadRequest - "Failed to parse: '#{@query}'. Falling back to legacy search." + if error.is_a? Searchkick::InvalidQueryError + "Failed to parse search term: '#{@query}'." else - Honeybadger.notify(error) - "Advanced search is currently unavailable. Falling back to legacy search." + Rails.error.report(error, handled: true) + "Search is currently unavailable. Please try again later." end end diff --git a/lib/fastly.rb b/lib/fastly.rb index 62b50fd3d64..67de7e1aad9 100644 --- a/lib/fastly.rb +++ b/lib/fastly.rb @@ -1,37 +1,54 @@ -require "net/http" +class Fastly + concerning :TraceTagging do + class_methods do + include TraceTagger + end + end -class Net::HTTP::Purge < Net::HTTPRequest - METHOD = "PURGE".freeze - REQUEST_HAS_BODY = false - RESPONSE_HAS_BODY = true -end + include SemanticLogger::Loggable + + # These are not kwargs because delayed_job doesn't correctly support kwargs in Fastly.delay.purge + # See: https://github.com/collectiveidea/delayed_job/issues/1134 + def self.purge(options = {}) + return unless ENV["FASTLY_DOMAINS"].present? && ENV["FASTLY_API_KEY"].present? + + connection = make_connection -class Fastly - def self.purge(path, soft: false) - return unless ENV["FASTLY_DOMAINS"] ENV["FASTLY_DOMAINS"].split(",").each do |domain| - url = "https://#{domain}/#{path}" - headers = soft ? { "Fastly-Soft-Purge" => 1 } : {} - - response = RestClient::Request.execute(method: :purge, - url: url, - timeout: 10, - headers: headers) - json = JSON.parse(response) - Rails.logger.debug "Fastly purge url=#{url} status=#{json['status']} id=#{json['id']}" + url = "https://#{domain}/#{options[:path]}" + trace("gemcutter.fastly.purge", resource: url, + tags: { "gemcutter.fastly.domain" => domain, "gemcutter.fastly.path" => options[:path], "gemcutter.fastly.soft" => options[:soft] }) do + headers = options[:soft] ? { "Fastly-Soft-Purge" => "1" } : {} + headers["Fastly-Key"] = ENV["FASTLY_API_KEY"] + + json = connection.get(url, nil, headers) do |req| + req.http_method = :purge + end + logger.debug { { message: "Fastly purge", url:, status: json["status"], id: json["id"] } } + end end end def self.purge_key(key, soft: false) - headers = { "Fastly-Key" => ENV["FASTLY_API_KEY"] } - headers["Fastly-Soft-Purge"] = 1 if soft - url = "https://api.fastly.com/service/#{ENV['FASTLY_SERVICE_ID']}/purge/#{key}" - response = RestClient::Request.execute(method: :post, - url: url, - timeout: 10, - headers: headers) - json = JSON.parse(response) - Rails.logger.debug "Fastly purge url=#{url} status=#{json['status']} id=#{json['id']}" - json + service_id = ENV["FASTLY_SERVICE_ID"] + return unless service_id.present? && ENV["FASTLY_API_KEY"].present? + + trace("gemcutter.fastly.purge_key", resource: key, tags: { "gemcutter.fastly.service_id" => service_id, "gemcutter.fastly.soft" => soft }) do + headers = { "Fastly-Key" => ENV["FASTLY_API_KEY"] } + headers["Fastly-Soft-Purge"] = "1" if soft + url = "https://api.fastly.com/service/#{service_id}/purge/#{key}" + json = make_connection.post(url, nil, headers) + logger.debug { { message: "Fastly purge", url:, status: json["status"], id: json["id"] } } + json + end + end + + def self.make_connection + Faraday.new(nil, request: { timeout: 10 }) do |f| + f.request :json + f.response :json + f.response :logger, logger, headers: false, errors: true + f.response :raise_error + end end end diff --git a/lib/gem_cache_purger.rb b/lib/gem_cache_purger.rb index b57219c1670..af0f5e46440 100644 --- a/lib/gem_cache_purger.rb +++ b/lib/gem_cache_purger.rb @@ -3,10 +3,12 @@ def self.call(gem_name) # We need to purge from Fastly and from Memcached ["info/#{gem_name}", "names"].each do |path| Rails.cache.delete(path) - Fastly.delay.purge(path, soft: true) + FastlyPurgeJob.perform_later(path:, soft: true) end - Rails.cache.delete("deps/v1/#{gem_name}") - Fastly.delay.purge("versions", soft: true) + FastlyPurgeJob.perform_later(path: "versions", soft: true) + FastlyPurgeJob.perform_later(path: "gem/#{gem_name}", soft: true) + FastlyPurgeJob.perform_later(key: "gem/#{gem_name}", soft: true) + FastlyPurgeJob.perform_later(key: "api/v1/activities", soft: true) end end diff --git a/lib/gem_package_enumerator.rb b/lib/gem_package_enumerator.rb new file mode 100644 index 00000000000..04c97385727 --- /dev/null +++ b/lib/gem_package_enumerator.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +## +# A wrapper around Gem::Package to provide an enumerator for files in the gem. + +class GemPackageEnumerator + def initialize(package) + @package = package + return if @package.respond_to?(:each_file) || @package.respond_to?(:verify) + raise ArgumentError, "package must be a Gem::Package" + end + + def each(&blk) + return enum_for(__method__).lazy unless blk + open_data_tar { |data_tar| data_tar.each(&blk) } + end + + def map(&) + each.lazy.map(&) + end + + def filter_map(&) + each.lazy.filter_map(&) + end + + private + + def open_data_tar(&blk) + @package.verify + @package.gem.with_read_io do |io| + Gem::Package::TarReader.new(io).seek("data.tar.gz") do |gem_entry| + @package.open_tar_gz(gem_entry, &blk) + end + end + end +end diff --git a/lib/gem_requirements_validator.rb b/lib/gem_requirements_validator.rb new file mode 100644 index 00000000000..4dd764b6448 --- /dev/null +++ b/lib/gem_requirements_validator.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class GemRequirementsValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + return if value.blank? + + return if value.split(", ").all? do |requirement| + requirement.length < Gemcutter::MAX_FIELD_LENGTH && Patterns::REQUIREMENT_PATTERN.match?(requirement) + end + record.errors.add(attribute, "must be list of valid requirements") + end +end diff --git a/lib/gemcutter/middleware.rb b/lib/gemcutter/middleware.rb new file mode 100644 index 00000000000..8135fd723ac --- /dev/null +++ b/lib/gemcutter/middleware.rb @@ -0,0 +1,2 @@ +module Gemcutter::Middleware +end diff --git a/lib/gemcutter/middleware/admin_auth.rb b/lib/gemcutter/middleware/admin_auth.rb new file mode 100644 index 00000000000..4037502321c --- /dev/null +++ b/lib/gemcutter/middleware/admin_auth.rb @@ -0,0 +1,59 @@ +require_relative "../middleware" +require_relative "../../github_oauthable" +require_relative "../../trace_tagger" + +class Gemcutter::Middleware::AdminAuth + def initialize(app) + @app = app + end + + def call(env) + Context.new(env).call || @app.call(env) + end + + class Context + include GitHubOAuthable + include TraceTagger + + def initialize(env) + @request = ActionDispatch::Request.new(env) + @cookies = request.cookie_jar + end + + attr_reader :request, :cookies + + def call + return unless requires_auth_for_admin?(request) + admin_user = find_admin_user + request.set_header(admin_user_request_header, admin_user) + if admin_user.present? + set_tag "gemcutter.admin_user.id", admin_user.id + return + end + return if allow_unauthenticated_request?(request) + login_page = ApplicationController.renderer.new(request.env).render(template: "avo/login", layout: false) + + headers = { "cache-control" => "private, max-age=0", "set-cookie" => cookies.to_header } + headers.compact_blank! + + [200, headers, [login_page]] + end + + private + + def requires_auth_for_admin?(request) + # always required on the admin instance + return true if request.host == Gemcutter::SEPARATE_ADMIN_HOST + + # always required for admin namespace + return true if request.path.match?(%r{\A/admin(/|\z)}) + + # running locally/staging, not trying to access admin namespace, safe to not require the admin auth + false + end + + def allow_unauthenticated_request?(request) + request.path.match?(%r{\A/oauth(/|\z)}) + end + end +end diff --git a/lib/gemcutter/middleware/hostess.rb b/lib/gemcutter/middleware/hostess.rb new file mode 100644 index 00000000000..8d127c9ac25 --- /dev/null +++ b/lib/gemcutter/middleware/hostess.rb @@ -0,0 +1,51 @@ +module Gemcutter::Middleware + class Hostess < Rack::Static + def initialize(app, options = {}) + options[:root] = RubygemFs.instance.base_dir + + options[:urls] = %w[ + /specs.4.8.gz + /latest_specs.4.8.gz + /prerelease_specs.4.8.gz + /quick/rubygems-update-1.3.6.gemspec.rz + /yaml.Z + /yaml.z + /Marshal.4.8.Z + /quick/index.rz + /quick/latest_index.rz + /yaml + /Marshal.4.8 + /specs.4.8 + /latest_specs.4.8 + /prerelease_specs.4.8 + /quick/index + /quick/latest_index + ] + + super + end + + def can_serve(path) + super || gem_download_path(path) || path =~ %r{^/quick/Marshal\.4\.8/.*\.gemspec.rz} + end + + def gem_download_path(path) + Regexp.last_match(1) if path =~ %r{^/gems/([^/]*)\.gem} + end + + def call(env) + path = env["PATH_INFO"] + + return [302, { "Location" => "/gems/#{Regexp.last_match(1)}.gem" }, []] if path =~ %r{^/downloads/([^/]*)\.gem} + + download_path = gem_download_path(path) + name = Version.rubygem_name_for(download_path) if download_path + if name + GemDownload.transaction do + GemDownload.bulk_update([[download_path, 1]]) + end + end + super + end + end +end diff --git a/lib/gemcutter/middleware/redirector.rb b/lib/gemcutter/middleware/redirector.rb new file mode 100644 index 00000000000..90bf668492b --- /dev/null +++ b/lib/gemcutter/middleware/redirector.rb @@ -0,0 +1,26 @@ +module Gemcutter::Middleware + class Redirector + def initialize(app) + @app = app + end + + def call(env) + request = Rack::Request.new(env) + + allowed_hosts = [Gemcutter::HOST, "index.rubygems.org", "fastly.rubygems.org", "bundler.rubygems.org", "rubygems.team"] + + if allowed_hosts.exclude?(request.host) && request.path !~ %r{^/api|^/internal} && request.host.exclude?("docs") + fake_request = Rack::Request.new(env.merge("HTTP_HOST" => Gemcutter::HOST)) + redirect_to(fake_request.url) + else + @app.call(env) + end + end + + private + + def redirect_to(url) + [301, { "Location" => url }, []] + end + end +end diff --git a/lib/gemcutter/request_ip_address.rb b/lib/gemcutter/request_ip_address.rb new file mode 100644 index 00000000000..bb497cd5479 --- /dev/null +++ b/lib/gemcutter/request_ip_address.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Gemcutter::RequestIpAddress + extend ActiveSupport::Concern + + GEOIP_FIELDS = { + continent_code: "GEOIP-CONTINENT-CODE", + country_code: "GEOIP-COUNTRY-CODE", + country_code3: "GEOIP-COUNTRY-CODE3", + country_name: "GEOIP-COUNTRY-NAME", + region: "GEOIP-REGION", + city: "GEOIP-CITY" + }.freeze + + PROXY_TOKEN = ENV["RUBYGEMS_PROXY_TOKEN"].presence.freeze + + included do + def ip_address + fetch_header("gemcutter.ip_address") do |k| + return if remote_ip.blank? + ip_addr = begin + IPAddr.new(remote_ip) + rescue IPAddr::InvalidAddressError + nil + end + return unless ip_addr + + addr = IpAddress.find_or_create_by(ip_address: ip_addr) + return unless addr + + token = headers["RUBYGEMS-PROXY-TOKEN"].presence + + if token && PROXY_TOKEN && ActiveSupport::SecurityUtils.secure_compare(token, PROXY_TOKEN) + values = GEOIP_FIELDS.transform_values { |v| headers[v] } + geoip_info = GeoipInfo.find_or_create_by(**values) + addr.update(geoip_info:) + end + + set_header k, addr + end + end + end +end diff --git a/lib/gemcutter/user_agent_parser.rb b/lib/gemcutter/user_agent_parser.rb new file mode 100644 index 00000000000..16ba77d9c70 --- /dev/null +++ b/lib/gemcutter/user_agent_parser.rb @@ -0,0 +1,158 @@ +class Gemcutter::UserAgentParser + class UnableToParse < ArgumentError; end + class MultipleParsersMatched < ArgumentError; end + + class Set + include SemanticLogger::Loggable + + def initialize + logger + @parsers = [] + end + + def register(parser) + @parsers << parser + parser + end + + def call(user_agent, exclusive: false) + ret = nil + @parsers.each do |parser| + res = parser.call(user_agent) + return res unless exclusive + raise MultipleParsersMatched, "Multiple parsers matched #{user_agent.inspect}" if ret + ret = res + rescue UnableToParse + next + rescue MultipleParsersMatched + raise + rescue StandardError => e + logger.error("Error parsing user agent: #{e.message}", user_agent:, parser: parser.name, error: e) + next + end + + return ret if ret + + logger.warn("No parser could parse the user agent", user_agent: user_agent) + + raise UnableToParse, "No parser could parse the user agent" + end + end + + class RegexUserAgentParser < Gemcutter::UserAgentParser + attr_reader :name + + def initialize(regexes, handler, name: nil) + super() + + @name = name || handler.name + @regexes = regexes + @handler = handler + end + + def call(user_agent) + @regexes.each do |regex| + next unless (match = regex.match(user_agent)) + group_to_name = match.regexp.named_captures.transform_values(&:sole).invert.transform_values(&:to_sym) + args = [] + kwargs = {} + match.to_a.each_with_index do |value, group| + next if group.zero? + if (name = group_to_name[group]) + kwargs[name] = value + else + args << value + end + end + return @handler.call(*args, **kwargs) + end + raise UnableToParse + end + end + + SET = Set.new + + def self.register(parser) + SET.register(parser) + end + + def self.regex_ua_parser(*regexps, method) + RegexUserAgentParser.new(regexps, method(method)) + end + + register regex_ua_parser \ + %r{^ + ( + Mozilla | + Safari | + wget | + curl | + Opera | + aria2 | + AndroidDownloadManager | + com\.apple\.WebKit\.Networking/ | + FDM\ \S+ | + URL/Emacs | + Firefox/ | + UCWEB | + Links | + ^okhttp | + ^Apache-HttpClient + ) + (/|$)(.*)}ix, + def self.browser_user_agent(*parts) + ua = USER_AGENT_PARSER.parse(parts.join) + Events::UserAgentInfo.new(installer: "Browser", device: ua.device&.family, os: ua.os&.family, user_agent: ua.family) + end + USER_AGENT_PARSER = UserAgentParser::Parser.new.freeze + + register regex_ua_parser \ + %r{\A + bundler/(?[0-9a-zA-Z.-]+) + [ ]rubygems/(?[0-9a-zA-Z.-]+) + [ ]ruby/(?[0-9a-zA-Z.-]+) + [ ]\((?[^)]*)\) + [ ]command/(?.*?) + (?:[ ]jruby/(?[0-9a-zA-Z.-]+))? + (?:[ ]truffleruby/(?[0-9a-zA-Z.-]+))? + (?:[ ]options/(?.*?))? + (?:[ ]ci/(?.*?))? + [ ](?[a-f0-9]{16}) + (?:[ ]Gemstash/(?[0-9a-zA-Z.-]+))? + \z + }ux, + def self.bundler_user_agent(platform:, jruby:, truffleruby:, **) + implementation = "Ruby" + implementation = "JRuby" if jruby + implementation = "TruffleRuby" if truffleruby + + Events::UserAgentInfo.new(installer: "Bundler", system: platform, implementation:) + end + + register regex_ua_parser \ + %r{\A + (?:Ruby,[ ])? + RubyGems/(?[0-9a-z.-]+)[ ] + (?.*)[ ] + Ruby/(?[0-9a-z.-]+)[ ] + \(.*?\) + (?:[ ](?jruby|truffleruby|rbx))? + (?:[ ]Gemstash/(?[0-9a-z.-]+))? + \z}x, + /\ARuby, Gems (?[0-9a-z.-]+)\z/, + def self.rubygems_user_agent(platform: nil, ruby_engine: nil, **) + Events::UserAgentInfo.new(installer: "RubyGems", system: platform, implementation: ruby_engine&.capitalize || "Ruby") + end + + register regex_ua_parser \ + %r{\Arubygems-oidc-action/?}x, + def self.rubygems_oidc_action_user_agent + Events::UserAgentInfo.new(installer: "RubyGems OIDC GitHub Action") + end + + SET.freeze + + def self.call(...) + SET.call(...) + end +end diff --git a/lib/github_oauthable.rb b/lib/github_oauthable.rb new file mode 100644 index 00000000000..04047d9d975 --- /dev/null +++ b/lib/github_oauthable.rb @@ -0,0 +1,108 @@ +module GitHubOAuthable + extend ActiveSupport::Concern + include SemanticLogger::Loggable + + INFO_QUERY = <<~GRAPHQL.freeze + query($organization_name:String!) { + viewer { + name + login + email + avatarUrl + id + organization(login: $organization_name) { + login + name + viewerIsAMember + teams(first: 20, role: MEMBER) { + edges { + node { + name + slug + } + } + } + } + } + } + GRAPHQL + + included do + before_action :set_error_context_user if respond_to?(:before_action) + + def admin_user + request.fetch_header(admin_user_request_header) do + find_admin_user + end + end + + def find_admin_user + return unless (cookie = cookies.encrypted[admin_cookie_name].presence) + Admin::GitHubUser.admins.find_by(id: cookie) + end + + def admin_logout + cookies.delete(admin_cookie_name) + redirect_to root_path + end + + def admin_github_login!(token:) + info_data = fetch_admin_user_info(token) + user = Admin::GitHubUser.find_or_initialize_by(github_id: info_data.dig(:viewer, :id)) + user.is_admin = true # will be set to false if the is_admin validation fails + user.oauth_token = token + user.info_data = info_data + if user.invalid? && user.errors.group_by_attribute.keys == %i[is_admin] + is_admin_error = ActiveModel::ValidationError.new(user) + user.is_admin = false + end + + # Avoid saving details for random people who go through the auth flow. + user.save! if user.is_admin || user.persisted? + + if user.is_admin + request.flash.now[:warning] = "Logged in as a admin via GitHub as #{user.name}" + log_in_as(user:) + else + request.flash[:error] = "#{user.name} on GitHub is not a valid admin" + raise is_admin_error + end + end + + def log_in_as(user:, expires: 1.hour) + cookies.encrypted[admin_cookie_name] = { + value: user.id, + expires: expires, + same_site: :strict + } + end + + def admin_cookie_name + "rubygems_admin_oauth_github_user" + end + + def admin_user_request_header + "gemcutter.rubygems_admin_oauth_github_user" + end + + def fetch_admin_user_info(oauth_token) + github_client = Octokit::Client.new(access_token: oauth_token) + graphql = github_client.post( + "/graphql", + { query: INFO_QUERY, variables: { organization_name: "rubygems" } }.to_json + ) + if (errors = graphql.errors.presence) + logger.warn("GitHub graphql errors", errors:) + end + graphql.data.to_h.deep_symbolize_keys + end + + def set_error_context_user + return unless admin_user + + Rails.error.set_context( + user_id: admin_user.github_id + ) + end + end +end diff --git a/lib/github_secret_scanning.rb b/lib/github_secret_scanning.rb new file mode 100644 index 00000000000..f49c3410012 --- /dev/null +++ b/lib/github_secret_scanning.rb @@ -0,0 +1,29 @@ +class GitHubSecretScanning + KEYS_URI = "https://api.github.com/meta/public_keys/secret_scanning".freeze + + def initialize(key_identifier) + @public_key = self.class.public_key(key_identifier) + end + + def valid_github_signature?(signature, body) + return false if @public_key.blank? + openssl_key = OpenSSL::PKey::EC.new(@public_key) + openssl_key.verify(OpenSSL::Digest.new("SHA256"), Base64.decode64(signature), body) + end + + def empty_public_key? + @public_key.blank? + end + + def self.public_key(id) + cache_key = ["GitHubSecretScanning", "public_keys", id] + Rails.cache.fetch(cache_key) do + public_keys = secret_scanning_keys.public_keys + public_keys&.find { |v| v.key_identifier == id }&.key + end + end + + def self.secret_scanning_keys + Octokit.client.get(KEYS_URI) + end +end diff --git a/lib/job_tags.rb b/lib/job_tags.rb new file mode 100644 index 00000000000..8e51def4801 --- /dev/null +++ b/lib/job_tags.rb @@ -0,0 +1,9 @@ +module JobTags + extend ActiveSupport::Concern + + included do + def statsd_tags + { queue: queue_name, priority: priority, job_class: self.class.name } + end + end +end diff --git a/lib/lograge/formatters/datadog.rb b/lib/lograge/formatters/datadog.rb deleted file mode 100644 index d822a217ea4..00000000000 --- a/lib/lograge/formatters/datadog.rb +++ /dev/null @@ -1,37 +0,0 @@ -module Lograge - module Formatters - class Datadog - include Lograge::Formatters::Helpers::MethodAndPath - - def call(data) - data.delete(:path) - { - timestamp: ::Time.now.utc, - env: Rails.env, - message: "[#{data[:status]}]#{method_and_path_string(data)}(#{data[:controller]}##{data[:action]})", - http: { - request_id: data.delete(:request_id), - method: data.delete(:method), - status_code: data.delete(:status), - response_time_ms: data.delete(:duration), - useragent: data.delete(:user_agent), - url: data.delete(:url) - }, - rails: { - controller: data.delete(:controller), - action: data.delete(:action), - params: data.delete(:params), - format: data.delete(:format), - view_time_ms: data.delete(:view), - db_time_ms: data.delete(:db) - }.compact, - network: { - client: { - ip: data.delete(:client_ip) - } - } - }.merge(data).to_json - end - end - end -end diff --git a/lib/middleware/hostess.rb b/lib/middleware/hostess.rb deleted file mode 100644 index 39963025759..00000000000 --- a/lib/middleware/hostess.rb +++ /dev/null @@ -1,49 +0,0 @@ -class Hostess < Rack::Static - def initialize(app, options = {}) - options[:root] = RubygemFs.instance.base_dir - - options[:urls] = %w[ - /specs.4.8.gz - /latest_specs.4.8.gz - /prerelease_specs.4.8.gz - /quick/rubygems-update-1.3.6.gemspec.rz - /yaml.Z - /yaml.z - /Marshal.4.8.Z - /quick/index.rz - /quick/latest_index.rz - /yaml - /Marshal.4.8 - /specs.4.8 - /latest_specs.4.8 - /prerelease_specs.4.8 - /quick/index - /quick/latest_index - ] - - super(app, options) - end - - def can_serve(path) - super(path) || gem_download_path(path) || path =~ %r{/quick/Marshal\.4\.8/.*\.gemspec.rz} - end - - def gem_download_path(path) - Regexp.last_match(1) if path =~ %r{/gems/(.*)\.gem} - end - - def call(env) - path = env["PATH_INFO"] - - return [302, { "Location" => "/gems/#{Regexp.last_match(1)}.gem" }, []] if path =~ %r{/downloads/(.*)\.gem} - - download_path = gem_download_path(path) - name = Version.rubygem_name_for(download_path) if download_path - if name - GemDownload.transaction do - GemDownload.bulk_update([[download_path, 1]]) - end - end - super - end -end diff --git a/lib/middleware/redirector.rb b/lib/middleware/redirector.rb deleted file mode 100644 index 89abbf99172..00000000000 --- a/lib/middleware/redirector.rb +++ /dev/null @@ -1,26 +0,0 @@ -class Redirector - def initialize(app) - @app = app - end - - def call(env) - request = Rack::Request.new(env) - - allowed_hosts = [Gemcutter::HOST, "index.rubygems.org", "fastly.rubygems.org", "bundler.rubygems.org", "insecure.rubygems.org"] - - if !allowed_hosts.include?(request.host) && request.path !~ %r{^/api|^/internal} && request.host !~ /docs/ - fake_request = Rack::Request.new(env.merge("HTTP_HOST" => Gemcutter::HOST)) - redirect_to(fake_request.url) - elsif request.path =~ %r{^/(book|chapter|export|read|shelf|syndicate)} && request.host !~ /docs/ - redirect_to("https://docs.rubygems.org#{request.path}") - else - @app.call(env) - end - end - - private - - def redirect_to(url) - [301, { "Location" => url }, []] - end -end diff --git a/lib/name_format_validator.rb b/lib/name_format_validator.rb new file mode 100644 index 00000000000..fa59e37b185 --- /dev/null +++ b/lib/name_format_validator.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class NameFormatValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + return record.errors.add attribute, "must be a String" if value.class != String + + record.errors.add attribute, "must include at least one letter" if requires_letter? && !Patterns::LETTER_REGEXP.match?(value) + record.errors.add attribute, "can only include letters, numbers, dashes, and underscores" unless Patterns::NAME_PATTERN.match?(value) + record.errors.add attribute, "can not begin with a period, dash, or underscore" if Patterns::SPECIAL_CHAR_PREFIX_REGEXP.match?(value) + record.errors.add attribute, "can not end with a period, dash, or underscore" if Patterns::SPECIAL_CHAR_SUFFIX_REGEXP.match?(value) + record.errors.add attribute, "can not end with a common file extension" if Patterns::BANNED_EXTENSION_REGEXP.match?(value) + end + + private + + def requires_letter? + options.fetch(:requires_letter, true) + end +end diff --git a/lib/nested_validator.rb b/lib/nested_validator.rb new file mode 100644 index 00000000000..7bf4d213047 --- /dev/null +++ b/lib/nested_validator.rb @@ -0,0 +1,19 @@ +class NestedValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + case value + when Array + value.each_with_index do |v, i| + next if v.valid? + v.errors.each do |e| + record.errors.import(e, attribute: "#{attribute}[#{i}].#{e.attribute}") + end + end + else + if Array(value).reject(&:valid?).any? + value.errors.each do |e| + record.errors.import(e, attribute: "#{attribute}.#{e.attribute}") + end + end + end + end +end diff --git a/lib/patterns.rb b/lib/patterns.rb index 0ff6fb2d05c..4b3a2674340 100644 --- a/lib/patterns.rb +++ b/lib/patterns.rb @@ -3,58 +3,17 @@ module Patterns SPECIAL_CHARACTERS = ".-_".freeze ALLOWED_CHARACTERS = "[A-Za-z0-9#{Regexp.escape(SPECIAL_CHARACTERS)}]+".freeze - ROUTE_PATTERN = /#{ALLOWED_CHARACTERS}/.freeze - LAZY_ROUTE_PATTERN = /#{ALLOWED_CHARACTERS}?/.freeze - NAME_PATTERN = /\A#{ALLOWED_CHARACTERS}\Z/.freeze - URL_VALIDATION_REGEXP = %r{\Ahttps?://([^\s:@]+:[^\s:@]*@)?[A-Za-z\d\-]+(\.[A-Za-z\d\-]+)+\.?(:\d{1,5})?([/?]\S*)?\z}.freeze - GEM_NAME_BLACKLIST = %w[ - cgi-session - complex - continuation - coverage - enumerator - expect - fiber - mkmf - profiler - pty - rational - rbconfig - socket - thread - unicode_normalize - ubygems - - jruby - mri - mruby - ruby - rubygems - install - uninstall - sidekiq-pro - graphql-pro - - action-cable - action_cable - action-mailer - action_mailer - action-pack - action_pack - action-view - action_view - active-job - active_job - active-model - active_model - active-record - active_record - active-storage - active_storage - active-support - active_support - sprockets_rails - rail-ties - rail_ties - ].freeze + ROUTE_PATTERN = /#{ALLOWED_CHARACTERS}(? rake-13.0.6.sha256 +# +# The output can be directly passed to shasum to check the files. +# In this example we fetch and unpack rack, then compare the checksums. +# +# gem fetch rake -v 13.0.6 +# gem unpack rake-13.0.6.gem +# cd rake-13.0.6 +# shasum -a 256 -c rake-13.0.6.sha256 +# +module ShasumFormat + class ParseError < RuntimeError; end + + # Splits a shasum file format into a Hash of path => checksum + # + # @param [String] body + # @return [Hash] path => checksum + def self.parse(body) + body.to_s.each_line(chomp: true).to_h do |line| + line.split(" ", 2).reverse + end + rescue StandardError => e + raise ParseError, e.message + end + + # Returns a file body matching the output of shasum command. + # + # @param [Hash] checksums path => checksum + # @return [String] file body + def self.generate(checksums) + checksums.sort.filter_map do |path, checksum| + next if path.blank? || checksum.blank? + "#{checksum} #{path}\n" + end.join + end +end diff --git a/lib/shoryuken/sqs_worker.rb b/lib/shoryuken/sqs_worker.rb index 44fafc0b360..e12dbc834dc 100644 --- a/lib/shoryuken/sqs_worker.rb +++ b/lib/shoryuken/sqs_worker.rb @@ -20,9 +20,10 @@ def perform(_sqs_msg, body) StatsD.increment("fastly_log_processor.enqueued") begin LogTicket.create!(backend: "s3", key: key, directory: bucket, status: "pending") - Delayed::Job.enqueue FastlyLogProcessor.new(bucket, key), priority: PRIORITIES[:stats] rescue ActiveRecord::RecordNotUnique StatsD.increment("fastly_log_processor.duplicated") + else + FastlyLogProcessorJob.perform_later(bucket:, key:) end end end diff --git a/lib/tasks/compact_index.rake b/lib/tasks/compact_index.rake index 7cf3d689e72..41df2efaf06 100644 --- a/lib/tasks/compact_index.rake +++ b/lib/tasks/compact_index.rake @@ -77,11 +77,12 @@ namespace :compact_index do desc "Generate/update the versions.list file" task update_versions_file: :environment do - file_path = Rails.application.config.rubygems["versions_file_location"] + ts = Time.now.utc.iso8601 + file_path = Rails.application.config.rubygems["versions_file_location"] versions_file = CompactIndex::VersionsFile.new file_path - gems = GemInfo.compact_index_public_versions + gems = GemInfo.compact_index_public_versions ts - versions_file.create gems + versions_file.create gems, ts version_file_content = File.read(file_path) RubygemFs.instance.store("versions/versions.list", version_file_content) diff --git a/lib/tasks/elasticsearch.rake b/lib/tasks/elasticsearch.rake deleted file mode 100644 index 98ba5b4fca1..00000000000 --- a/lib/tasks/elasticsearch.rake +++ /dev/null @@ -1,32 +0,0 @@ -require "elasticsearch/rails/tasks/import" - -namespace :elasticsearch do - task :drop do - Rubygem.__elasticsearch__.client.indices.delete index: Rubygem.index_name - end - task :create do - Rubygem.__elasticsearch__.create_index! force: true - Rubygem.__elasticsearch__.refresh_index! - end - - task :import_alias do - old_idx = Rubygem.__elasticsearch__.client.cat.aliases(name: Rubygem.index_name, h: ["index"]).strip - new_idx = "#{Rubygem.index_name}-#{Time.zone.now.strftime('%Y%m%d%H%M')}" - - res = Rubygem.__elasticsearch__.client.count index: old_idx - puts "Count before import: #{res['count']}" - - Rubygem.import index: new_idx, force: true - - res = Rubygem.__elasticsearch__.client.count index: new_idx - puts "Count after import: #{res['count']}" - - Rubygem.__elasticsearch__.client.indices.update_aliases body: { - actions: [ - { remove: { index: old_idx, alias: Rubygem.index_name } }, - { add: { index: new_idx, alias: Rubygem.index_name } } - ] - } - Rubygem.__elasticsearch__.delete_index! index: old_idx - end -end diff --git a/lib/tasks/extraneous_dependencies.rake b/lib/tasks/extraneous_dependencies.rake deleted file mode 100644 index 6620f65f126..00000000000 --- a/lib/tasks/extraneous_dependencies.rake +++ /dev/null @@ -1,111 +0,0 @@ -require "tasks/helpers/compact_index_tasks_helper" - -namespace :extraneous_dependencies do - def fetch_spec_deps(full_name) - spec_uri = URI("https://rubygems.org/quick/Marshal.4.8/#{full_name}.gemspec.rz") - http = Net::HTTP.new(spec_uri.host, spec_uri.port) - http.use_ssl = true - - request = Net::HTTP::Get.new(spec_uri.request_uri) - res = http.request(request) - - raise StandardError, "fetch deps request for #{full_name} failed #{res.inspect}" unless res.code == "200" - - spec_obj = Marshal.load(Gem::Util.inflate(res.body)) - - spec_run_deps = spec_obj.dependencies.map do |s| - s.name.to_s.downcase if s.type == :runtime && Rubygem.where(name: s.name.to_s).present? - end.compact.sort - - spec_dev_deps = spec_obj.dependencies.map do |s| - s.name.to_s.downcase if s.type == :development && Rubygem.where(name: s.name.to_s).present? - end.compact.sort - - [spec_run_deps, spec_dev_deps] - end - - desc "Remove dependencies from DB where it doesn't match the gemspec" - task clean: :environment do |task| - ActiveRecord::Base.logger.level = 1 if Rails.env.development? - - versions = Version.joins("inner join dependencies on versions.id = dependencies.version_id") - .where("date_trunc('day', dependencies.created_at) = '2009-09-02 00:00:00'::timestamp") - .where("versions.indexed = 'true'") - .distinct("versions.id") - - total = versions.count - processed = 0 - errored = 0 - dev_mis_match = 0 - run_mis_match = 0 - mis_match_versions = 0 - total_deleted_deps = 0 - - Rails.logger.info "[extraneous_dependencies:clean] found #{total} versions for clean up" - versions.each do |version| - print format("\r%.2f%% (%d/%d) complete", processed.to_f / total * 100.0, processed, total) - - spec_run_deps, spec_dev_deps = fetch_spec_deps(version.full_name) - - db_run_deps = {} - db_dev_deps = {} - db_deps = version.dependencies.to_a - db_deps.each do |d| - db_run_deps[d.id.to_s] = d.rubygem.name.downcase if d.scope == "runtime" && d.rubygem.present? - db_dev_deps[d.id.to_s] = d.rubygem.name.downcase if d.scope == "development" && d.rubygem.present? - end - - deps_to_delete = [] - if spec_run_deps != db_run_deps.values.sort - unique_run_devs = [] - db_run_deps.each do |id, name| - deps_to_delete << id unless spec_run_deps.include?(name) - - if unique_run_devs.include?(name) - deps_to_delete << id - else - unique_run_devs << name - end - end - - run_mis_match += 1 - Rails.logger.info("[extraneous_dependencies:clean] spec and db run deps don't match "\ - "for: #{version.full_name} spec: #{spec_run_deps} db: #{db_run_deps}") - end - - if spec_dev_deps != db_dev_deps.values.sort - unique_dev_deps = [] - db_dev_deps.sort.to_h.each do |id, name| - if unique_dev_deps.include?(name) - deps_to_delete << id - else - unique_dev_deps << name - end - end - - dev_mis_match += 1 - Rails.logger.info("[extraneous_dependencies:clean] spec and db dev deps don't match "\ - "for: #{version.full_name} spec: #{spec_dev_deps} db: #{db_dev_deps}") - end - - if deps_to_delete.present? - mis_match_versions += 1 - total_deleted_deps += deps_to_delete.count - Rails.logger.info("[extraneous_dependencies:clean] deleting dependencies with ids: #{deps_to_delete}") - Dependency.destroy(deps_to_delete) - - CompactIndexTasksHelper.update_last_checksum(version.rubygem, task) - end - rescue StandardError => e - errored += 1 - Rails.logger.error("[extraneous_dependencies:clean] skipping #{version.inspect} - #{e.message}") - ensure - processed += 1 - end - - Rails.logger.info("[extraneous_dependencies:clean] #{total_deleted_deps} dependencies deleted") - Rails.logger.info("[extraneous_dependencies:clean] #{errored}/#{processed} errors") - Rails.logger.info("[extraneous_dependencies:clean] #{mis_match_versions}/#{processed} version mismatches " \ - "(run_deps: #{run_mis_match}, dev_deps: #{dev_mis_match})") - end -end diff --git a/lib/tasks/gemcutter.rake b/lib/tasks/gemcutter.rake index 4e77139484e..c2e0c61935c 100644 --- a/lib/tasks/gemcutter.rake +++ b/lib/tasks/gemcutter.rake @@ -1,5 +1,3 @@ -require "tasks/helpers/gemcutter_tasks_helper" - namespace :gemcutter do namespace :index do desc "Update the index" @@ -13,8 +11,8 @@ namespace :gemcutter do namespace :import do desc "Bring the gems through the gemcutter process" - task process: :environment do - gems = Dir[File.join(ARGV[1] || "#{Gem.path.first}/cache", "*.gem")].sort.reverse + task :process, %i[gems_cache_path] => :environment do |_task, args| + gems = Dir[File.join(args[:gems_cache_path] || "#{Gem.path.first}/cache", "*.gem")].reverse puts "Processing #{gems.size} gems..." gems.each do |path| puts "Processing #{path}" @@ -26,84 +24,6 @@ namespace :gemcutter do end end - namespace :checksums do - desc "Initialize missing checksums." - task init: :environment do - without_sha256 = Version.where(sha256: nil) - mod = ENV["shard"] - without_sha256.where("id % 4 = ?", mod.to_i) if mod - - total = without_sha256.count - i = 0 - without_sha256.find_each do |version| - GemcutterTaskshelper.recalculate_sha256!(version) - i += 1 - print format("\r%.2f%% (%d/%d) complete", i.to_f / total * 100.0, i, total) - end - puts - puts "Done." - end - - desc "Check existing checksums." - task check: :environment do - failed = false - i = 0 - total = Version.count - Version.find_each do |version| - actual_sha256 = GemcutterTaskshelper.recalculate_sha256(version.full_name) - if actual_sha256 && version.sha256 != actual_sha256 - puts "#{version.full_name}.gem has sha256 '#{actual_sha256}', " \ - "but '#{version.sha256}' was expected." - failed = true - end - i += 1 - print format("\r%.2f%% (%d/%d) complete", i.to_f / total * 100.0, i, total) - end - end - end - - namespace :metadata do - desc "Backfill old gem versions with metadata." - task backfill: :environment do - without_metadata = Version.where("metadata = ''") - mod = ENV["shard"] - without_metadata = without_metadata.where("id % 4 = ?", mod.to_i) if mod - - total = without_metadata.count - i = 0 - puts "Total: #{total}" - without_metadata.find_each do |version| - GemcutterTaskshelper.recalculate_metadata!(version) - i += 1 - print format("\r%.2f%% (%d/%d) complete", i.to_f / total * 100.0, i, total) - end - puts - puts "Done." - end - end - - namespace :required_ruby_version do - desc "Backfill gem versions with rubygems_version." - task backfill: :environment do - ActiveRecord::Base.logger.level = 1 if Rails.env.development? - - without_required_ruby_version = Version.where("created_at < '2014-03-21' and required_ruby_version is null and indexed = true") - mod = ENV["shard"] - without_required_ruby_version = without_required_ruby_version.where("id % 4 = ?", mod.to_i) if mod - - total = without_required_ruby_version.count - i = 0 - puts "Total: #{total}" - without_required_ruby_version.find_each do |version| - GemcutterTaskshelper.assign_required_ruby_version!(version) - i += 1 - print format("\r%.2f%% (%d/%d) complete", i.to_f / total * 100.0, i, total) - end - puts - puts "Done." - end - end - namespace :typo do desc "Add names to gem typo exception list\nUsage: rake gemcutter:typo:exception[,]" task :exception, %i[name info] => %i[environment] do |_task, args| @@ -127,8 +47,8 @@ namespace :gemcutter do rubygems_without_total_downloads.each do |rubygem| total_downloads = GemDownload.where(rubygem_id: rubygem.id).sum(:count) GemDownload.create!(count: total_downloads, rubygem_id: rubygem.id, version_id: 0) - Rails.logger.info "[gemcutter:gem_downloads:add_rubygems_record] added GemDownloads for rubygem_id: #{rubygem.id} with "\ - "total downloads: #{total_downloads}" + Rails.logger.info "[gemcutter:gem_downloads:add_rubygems_record] added GemDownloads for rubygem_id: #{rubygem.id} with " \ + "total downloads: #{total_downloads}" processed += 1 print format("\r%.2f%% (%d/%d) complete", processed.to_f / total * 100.0, processed, total) end diff --git a/lib/tasks/gen_erd.rake b/lib/tasks/gen_erd.rake index da45e8f95d4..78c2523bdd9 100644 --- a/lib/tasks/gen_erd.rake +++ b/lib/tasks/gen_erd.rake @@ -1,5 +1,6 @@ desc "Generate an ERD for the app" -task :gen_erd do +task gen_erd: :environment do title = "RubyGems.org domain model" `bundle exec rake erd filetype=svg filename=doc/erd orientation=vertical title="#{title}"` + `bundle exec rake erd filetype=dot filename=doc/erd orientation=vertical title="#{title}"` end diff --git a/lib/tasks/helpers/gemcutter_tasks_helper.rb b/lib/tasks/helpers/gemcutter_tasks_helper.rb deleted file mode 100644 index bc5b13d120b..00000000000 --- a/lib/tasks/helpers/gemcutter_tasks_helper.rb +++ /dev/null @@ -1,42 +0,0 @@ -module GemcutterTaskshelper - module_function - - def recalculate_sha256(version_full_name) - key = "gems/#{version_full_name}.gem" - file = RubygemFs.instance.get(key) - Digest::SHA2.base64digest(file) if file - end - - def recalculate_sha256!(version) - sha256 = recalculate_sha256(version.full_name) - version.update(sha256: sha256) - end - - def recalculate_metadata!(version) - metadata = get_spec_attribute(version.full_name, "metadata") - version.update(metadata: metadata || {}) - end - - def assign_required_ruby_version!(version) - required_ruby_version = get_spec_attribute(version.full_name, "required_ruby_version") - - return if required_ruby_version.nil? || required_ruby_version.to_s == ">= 0" - Rails.logger.info("[gemcutter:required_ruby_version:backfill] updating version: #{version.full_name} "\ - " with required_ruby_version: #{required_ruby_version}") - - version.update_column(:required_ruby_version, required_ruby_version.to_s) - CompactIndexTasksHelper.update_last_checksum(version.rubygem, "gemcutter:required_ruby_version:backfill") - end - - def get_spec_attribute(version_full_name, attribute_name) - key = "quick/Marshal.4.8/#{version_full_name}.gemspec.rz" - file = RubygemFs.instance.get(key) - return nil unless file - spec = Marshal.load(Gem::Util.inflate(file)) - spec.send(attribute_name) - rescue StandardError => e - Rails.logger.info("[gemcutter:required_ruby_version:backfill] could not get required_ruby_version for version: #{version.full_name}"\ - " error: #{e.inspect}") - nil - end -end diff --git a/lib/tasks/helpers/importmap_helper.rb b/lib/tasks/helpers/importmap_helper.rb new file mode 100644 index 00000000000..861bcc589ca --- /dev/null +++ b/lib/tasks/helpers/importmap_helper.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "importmap/packager" + +module ImportmapHelper + VerifyError = Class.new(StandardError) + + class Packager < Importmap::Packager + self.endpoint = URI("https://api.jspm.io/generate") + + # Copied from https://github.com/rails/importmap-rails/pull/237 + def verify(package, url) + ensure_vendor_directory_exists + + return unless vendored_package_path(package).file? + verify_vendored_package(package, url) + end + + def verify_vendored_package(package, url) + vendored_body = vendored_package_path(package).read.strip + remote_body = load_package_file(package, url).strip + + return true if vendored_body == remote_body + + raise ImportmapHelper::VerifyError, "Vendored #{package}#{extract_package_version_from(url)} does not match remote #{url}" + end + + def load_package_file(package, url) + response = Net::HTTP.get_response(URI(url)) + + if response.code == "200" + format_vendored_package(package, url, response.body) + else + handle_failure_response(response) + end + end + + def format_vendored_package(package, url, source) + formatted = +"" + if Gem::Version.new(Importmap::VERSION) > Gem::Version.new("2.0.1") + formatted.concat "// #{package}#{extract_package_version_from(url)} downloaded from #{url}\n\n" + end + formatted.concat remove_sourcemap_comment_from(source).force_encoding("UTF-8") + formatted + end + + def save_vendored_package(package, _url, source) + File.write(vendored_package_path(package), source) + end + + public :vendored_package_path + end +end diff --git a/lib/tasks/importmap.rake b/lib/tasks/importmap.rake new file mode 100644 index 00000000000..e941b84d6c2 --- /dev/null +++ b/lib/tasks/importmap.rake @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "importmap/npm" +require "tasks/helpers/importmap_helper" + +namespace :importmap do + desc "Verify downloaded packages in vendor/javascript" + task :verify do # rubocop:disable Rails/RakeEnvironment + all_files = Rails.root.glob("vendor/javascript/*.js").map { |p| p.relative_path_from(Rails.root) } + all_files.delete(Pathname.new("vendor/javascript/github-buttons.js")) || raise("importmap:verify expected github-buttons.js not found") + all_files.delete(Pathname.new("vendor/javascript/webauthn-json.js")) || raise("importmap:verify expected webauthn-json.js not found") + + npm = Importmap::Npm.new(Rails.root.join("config/importmap.rb")) + + packages = npm.packages_with_versions.map do |p, v| + v.blank? ? p : [p, v].join("@") + end + + puts "Verifying packages in vendor/javascript" + + packager = ImportmapHelper::Packager.new + + if (imports = packager.import(*packages, env: "production", from: "jspm.io")) + imports.each do |package, url| + puts %(Verifying "#{package}" download from #{url}) + packager.verify(package, url) + path = packager.vendored_package_path(package) + puts %(Verified "#{package}" at #{path}) + all_files.delete path + end + + if all_files.empty? + puts "All pinned js in vendor/javascript verified." + else + puts "Remaining files in vendor not verified:" + # ignore known manually vendored files or raise if they get deleted without updating the task + + all_files.each do |f| + puts " - #{f}" + end + exit 1 + end + else + warn "No packages found" + exit 1 + end + end +end diff --git a/lib/tasks/linkset.rake b/lib/tasks/linkset.rake index eca3a2a947e..386762554e8 100644 --- a/lib/tasks/linkset.rake +++ b/lib/tasks/linkset.rake @@ -14,7 +14,7 @@ namespace :linkset do affected = invalid_links.update_all(["home = ?", nil]) puts "Successfully removed #{affected} urls in home" end - rescue + rescue StandardError puts "Error: Couldn't update urls" end end diff --git a/lib/tasks/mfa_notification.rake b/lib/tasks/mfa_notification.rake index 466ee5c9117..a5e3bf233f7 100644 --- a/lib/tasks/mfa_notification.rake +++ b/lib/tasks/mfa_notification.rake @@ -27,7 +27,7 @@ namespace :mfa_notification do i = 0 notify_users.find_each do |user| - Mailer.delay.mfa_notification(user.id) if mx_exists?(user.email) + Mailer.mfa_notification(user.id).deliver_later if mx_exists?(user.email) i += 1 print format("\r%.2f%% (%d/%d) complete", i.to_f / total * 100.0, i, total) end diff --git a/lib/tasks/mfa_policy.rake b/lib/tasks/mfa_policy.rake new file mode 100644 index 00000000000..57010b97fc5 --- /dev/null +++ b/lib/tasks/mfa_policy.rake @@ -0,0 +1,82 @@ +require "resolv" + +def mx_exists?(email) + domain = email.split("@").last + mx_resolver = Resolv::DNS.new + mx_resolver.timeouts = 10 + + return false if mx_resolver.getresources(domain, Resolv::DNS::Resource::IN::MX).empty? + true +rescue StandardError => e + puts "Error during processing: #{$ERROR_INFO}" + puts "Backtrace:\n\t#{e.backtrace.join("\n\t")}" + false +end + +namespace :mfa_policy do + # This task is meant to be run on MFA Phase 2 launch day - June 13, 2022 + # For more information on the MFA Phase 2 rollout, refer to this RFC: + # https://github.com/rubygems/rfcs/pull/36/files#diff-3d5cc3acc06fe7e9150fdbfc43399c5ad42572c122187774bfc3a4857df524f1R46-R67 + # rake mfa_policy:announce_recommendation + desc "Send email notification to all users about MFA Phase 2 rollout (MFA Recommendation for popular gems)" + task announce_recommendation: :environment do + # users who own at least one gem with a minimum of 165,000,000 downloads or more + users = User.joins(rubygems: :gem_download).where("gem_downloads.count >= 165000000").uniq + total_users = users.count + puts "Sending #{total_users} MFA announcement email" + + i = 0 + users.each do |user| + Mailer.mfa_recommendation_announcement(user.id).deliver_later if mx_exists?(user.email) + i += 1 + print format("\r%.2f%% (%d/%d) complete", i.to_f / total_users * 100.0, i, total_users) + end + end + + # This task is meant to be run one week prior to MFA Phase 3 launch day - send out on Aug 8, 2022 + # For more information on the MFA Phase 3 rollout, refer to this RFC: + # https://github.com/rubygems/rfcs/pull/36/files#diff-3d5cc3acc06fe7e9150fdbfc43399c5ad42572c122187774bfc3a4857df524f1R69-R85 + # rake mfa_policy:reminder_enable_mfa + desc "Send email reminder to users who will have MFA enforced about impending MFA Phase 3 rollout" + task reminder_enable_mfa: :environment do + # users who own at least one gem with 180,000,000 downloads or more with weak or no MFA + users = User.joins(rubygems: :gem_download).where("gem_downloads.count >= 180000000").where(mfa_level: %w[disabled ui_only]) + total_users = users.count + puts "Sending #{total_users} MFA reminder email" + + i = 0 + users.each do |user| + Mailer.mfa_required_soon_announcement(user.id).deliver_later if mx_exists?(user.email) + i += 1 + print format("\r%.2f%% (%d/%d) complete", i.to_f / total_users * 100.0, i, total_users) + end + end + + # This task is meant to be run once MFA Phase 3 has launched - send out on Aug 15, 2022 + # For more information on the MFA Phase 3 rollout, refer to this RFC: + # https://github.com/rubygems/rfcs/pull/36/files#diff-3d5cc3acc06fe7e9150fdbfc43399c5ad42572c122187774bfc3a4857df524f1R69-R85 + # rake mfa_policy:announce_enforcement_for_popular_gems + desc "Send email to notify users that MFA is now being enforced due to MFA Phase 3 rollout" + task announce_enforcement_for_popular_gems: :environment do + # users who own at least one gem with 180,000,000 downloads or more, with weak MFA or no MFA enabled + users = User.joins(rubygems: :gem_download).where("gem_downloads.count >= 180000000").where(mfa_level: %w[disabled ui_only]).uniq + total_users = users.count + puts "Sending #{total_users} MFA required for popular gems email" + + mailers_sent = 0 + mailers_not_sent = 0 + unsent_mailer_emails = [] + users.each do |user| + if mx_exists?(user.email) + Mailer.mfa_required_popular_gems_announcement(user.id).deliver_later + mailers_sent += 1 + print format("\r%.2f%% (%d/%d) complete", mailers_sent.to_f / total_users * 100.0, mailers_sent, total_users) + else + mailers_not_sent += 1 + unsent_mailer_emails << user.email + end + end + + puts "Mailer was not sent to #{mailers_not_sent} account(s):\n#{unsent_mailer_emails.join(", \n")}" if unsent_mailer_emails.any? + end +end diff --git a/lib/tasks/multifactor_auth.rake b/lib/tasks/multifactor_auth.rake new file mode 100644 index 00000000000..5d81c20996f --- /dev/null +++ b/lib/tasks/multifactor_auth.rake @@ -0,0 +1,22 @@ +namespace :multifactor_auth do + desc "Migrate user mfa level from ui_only to ui_and_gem_signin" + task migrate_ui_only: :environment do + users = User.where(mfa_level: :ui_only) + + total = users.count + puts "Total: #{total}" + + completed_migrations = 0 + users.find_each do |user| + begin + user.update!(mfa_level: :ui_and_gem_signin) + rescue StandardError => e + puts "Cannot update mfa level for: #{user.handle}" + puts "Caught exception: #{e}" + end + + completed_migrations += 1 + print format("\r%.2f%% (%d/%d) complete", completed_migrations.to_f / total * 100.0, completed_migrations, total) + end + end +end diff --git a/lib/tasks/ownership_requests_mailer.rake b/lib/tasks/ownership_requests_mailer.rake new file mode 100644 index 00000000000..4bcc90b3f04 --- /dev/null +++ b/lib/tasks/ownership_requests_mailer.rake @@ -0,0 +1,15 @@ +namespace :ownership_request_notification do + desc "Send email notification about ownership requests to the owners" + task send: :environment do + gems_with_requests = OwnershipRequest.where(created_at: 24.hours.ago..Time.current).pluck(:rubygem_id).uniq + return unless gems_with_requests + + gems_with_requests.each do |rubygem_id| + rubygem = Rubygem.find(rubygem_id) + + rubygem.ownership_request_notifiable_owners.each do |user| + OwnersMailer.new_ownership_requests(rubygem_id, user.id).deliver_later + end + end + end +end diff --git a/lib/tasks/rubocop.rake b/lib/tasks/rubocop.rake index aecce2f360b..0263b8a3a49 100644 --- a/lib/tasks/rubocop.rake +++ b/lib/tasks/rubocop.rake @@ -1,5 +1,4 @@ begin - require "rubocop" require "rubocop/rake_task" rescue LoadError # rubocop:disable Lint/SuppressedException else diff --git a/lib/trace_tagger.rb b/lib/trace_tagger.rb new file mode 100644 index 00000000000..f7a575beb09 --- /dev/null +++ b/lib/trace_tagger.rb @@ -0,0 +1,13 @@ +module TraceTagger + extend ActiveSupport::Concern + + included do + delegate :set_tag, :set_tags, to: "Datadog::Tracing.active_span", allow_nil: true + + def trace(...) + return yield unless Datadog::Tracing.enabled? + + Datadog::Tracing.trace(...) + end + end +end diff --git a/lib/types/array_of.rb b/lib/types/array_of.rb new file mode 100644 index 00000000000..dc79144441b --- /dev/null +++ b/lib/types/array_of.rb @@ -0,0 +1,18 @@ +class Types::ArrayOf < ActiveModel::Type::Value + def initialize(klass) + @klass = klass + super() + end + + def type = :array_of + + def changed_in_place?(raw_old_value, new_value) + deserialize(raw_old_value) != new_value + end + + def cast_value(value) + value&.map { member.cast(_1) } + end + + def member = @klass.is_a?(Symbol) ? ActiveModel::Type.lookup(@klass) : @klass +end diff --git a/lib/types/duration.rb b/lib/types/duration.rb new file mode 100644 index 00000000000..892df002cf4 --- /dev/null +++ b/lib/types/duration.rb @@ -0,0 +1,28 @@ +class Types::Duration < ActiveModel::Type::Value + def cast_value(value) + case value + when NilClass, ActiveSupport::Duration + value + when String + if /\A\d+\z/.match?(value) + ActiveSupport::Duration.build(value.to_i) + else + ActiveSupport::Duration.parse(value) + end + when Integer + ActiveSupport::Duration.build(value) + else + raise ArgumentError, "Cannot cast #{value.inspect} to a Duration" + end + rescue ActiveSupport::Duration::ISO8601Parser::ParsingError + nil + end + + def serialize(duration) + duration.presence&.iso8601 + end + + def type_cast_for_schema(value) + serialize(value).inspect + end +end diff --git a/lib/types/global_id.rb b/lib/types/global_id.rb new file mode 100644 index 00000000000..3e0b8f5c5fe --- /dev/null +++ b/lib/types/global_id.rb @@ -0,0 +1,6 @@ +class Types::GlobalId < ActiveRecord::Type::String + def cast_value(value) = value.nil? || value.is_a?(::GlobalID) ? super : ::GlobalID.parse(super) + def deserialize(value) = cast_value(super) + + def type = :global_id +end diff --git a/lib/types/json_deserializable.rb b/lib/types/json_deserializable.rb new file mode 100644 index 00000000000..9f2fba38ee2 --- /dev/null +++ b/lib/types/json_deserializable.rb @@ -0,0 +1,9 @@ +class Types::JsonDeserializable < ActiveRecord::Type::Json + def initialize(klass) + @klass = klass + super() + end + + def cast_value(value) = value.nil? || value.is_a?(@klass) ? super : @klass.new(super) + def deserialize(value) = cast_value(super) +end diff --git a/public/images/avatar.svg b/public/images/avatar.svg new file mode 100644 index 00000000000..9395ef2d958 --- /dev/null +++ b/public/images/avatar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/avatar_inverted.svg b/public/images/avatar_inverted.svg new file mode 100644 index 00000000000..8a89eea5d7e --- /dev/null +++ b/public/images/avatar_inverted.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/twitter_icon.png b/public/images/twitter_icon.png deleted file mode 100644 index 739f534f772..00000000000 Binary files a/public/images/twitter_icon.png and /dev/null differ diff --git a/public/images/x_icon.png b/public/images/x_icon.png new file mode 100644 index 00000000000..63c79a59107 Binary files /dev/null and b/public/images/x_icon.png differ diff --git a/public/robots.txt b/public/robots.txt index 1fab05ef6e9..a1bc7a1368b 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,3 +1,4 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file User-agent: * Disallow: /downloads/ Disallow: /gems?letter=* diff --git a/public/sponsors.png b/public/sponsors.png index 057226b283f..867acc4296b 100644 Binary files a/public/sponsors.png and b/public/sponsors.png differ diff --git a/public/stylesheets/static.css b/public/stylesheets/static.css index 2f028c0ff2c..dd45b44a7d6 100644 --- a/public/stylesheets/static.css +++ b/public/stylesheets/static.css @@ -26,7 +26,6 @@ img { width: 100%; } - .wrapper { margin-right: auto; margin-left: auto; @@ -76,3 +75,20 @@ img { animation-timing-function: ease-in-out; } } + +button#github-login { + background-color: black; + color: white; + margin: auto; + padding: 15px 20px; + border-radius: 6px; + display: flex; + align-items: center; + gap: 20px; + font-size: 18px; + font-weight: bold; +} + +.admin-list { + list-style-type: none; +} diff --git a/script/add_owner b/script/add_owner index e10bb7b6865..1a11324aac5 100755 --- a/script/add_owner +++ b/script/add_owner @@ -10,7 +10,7 @@ require_relative "../config/environment" user = User.find_by_email!(email) rubygem = Rubygem.find_by_name!(name) -authorizer = User.find_by_email!("security@rubygems.org") +authorizer = User.security_user rubygem.ownerships.create(user_id: user.id, authorizer: authorizer, confirmed_at: Time.current) diff --git a/script/build_docker.sh b/script/build_docker.sh index 8b0dd1000f2..52437389d54 100755 --- a/script/build_docker.sh +++ b/script/build_docker.sh @@ -2,49 +2,89 @@ set -ex -if [ -z "$RUBYGEMS_VERSION" ] || [ $RUBYGEMS_VERSION == 'latest' ] -then +if [ -z "$RUBYGEMS_VERSION" ] || [ "$RUBYGEMS_VERSION" == 'latest' ]; then exit 0 fi -if [ -z "$TRAVIS_RUBY_VERSION" ] || [ $TRAVIS_RUBY_VERSION == 'ruby-head' ] -then +if [ -z "$RUBY_VERSION" ] || [ "$RUBY_VERSION" == 'ruby-head' ]; then exit 0 fi -echo "$TRAVIS_COMMIT" > REVISION +# make repository lower-case, since it is used in docker image tag +# and must be lowercase +GITHUB_REPOSITORY=$(echo "${GITHUB_REPOSITORY:-rubygems/rubygems.org}" | tr '[:upper:]' '[:lower:]') -docker build -t quay.io/$TRAVIS_REPO_SLUG:$TRAVIS_COMMIT --build-arg RUBYGEMS_VERSION=$RUBYGEMS_VERSION . +DOCKER_TAG="048268392960.dkr.ecr.us-west-2.amazonaws.com/$GITHUB_REPOSITORY:$GITHUB_SHA" -docker run -e RAILS_ENV=production -e SECRET_KEY_BASE=1234 -e DATABASE_URL=postgresql://localhost \ - --net host quay.io/$TRAVIS_REPO_SLUG:$TRAVIS_COMMIT \ - -- rake db:create db:migrate -docker run -d -e RAILS_ENV=production -e SECRET_KEY_BASE=1234 -e DATABASE_URL=postgresql://localhost \ - --net host quay.io/$TRAVIS_REPO_SLUG:$TRAVIS_COMMIT \ - -- unicorn_rails -E production -c /app/config/unicorn.conf +docker buildx build --cache-from=type=local,src=/tmp/.buildx-cache \ + --cache-to=mode=max,type=local,dest=/tmp/.buildx-cache-new \ + --output type=docker \ + --tag "$DOCKER_TAG" \ + --build-arg RUBYGEMS_VERSION="$RUBYGEMS_VERSION" \ + --build-arg REVISION="$GITHUB_SHA" \ + . + +# This is a ruby script we run to ensure that all dependencies are configured properly in +# the docker container, even if they are not used in the the few requests made to the application. +docker run -e RAILS_ENV=production -e SECRET_KEY_BASE_DUMMY=1 -e DATABASE_URL=postgresql://localhost \ + --net host "$DOCKER_TAG" \ + -- bin/rails runner - <<-EOS +Magic.buffer('') +EOS + +docker run -e RAILS_ENV=production -e SECRET_KEY_BASE_DUMMY=1 -e DATABASE_URL=postgresql://localhost \ + --net host "$DOCKER_TAG" \ + -- bin/rails db:create db:migrate +docker run -d -e RAILS_ENV=production -e SECRET_KEY_BASE_DUMMY=1 -e DATABASE_URL=postgresql://localhost \ + --net host "$DOCKER_TAG" \ + -- puma --environment production --config /app/config/puma.rb sleep 5 -curl -m 5 http://localhost:3000/internal/ping | grep PONG +pong=$(curl -m 5 http://localhost:3000/internal/ping || true) -if [ $? -eq 1 ]; then +if [ "${pong}" != "PONG" ]; then echo "Internal ping api test didn't pass." - docker ps -aqf "ancestor=quay.io/$TRAVIS_REPO_SLUG:$TRAVIS_COMMIT" | xargs -Iid docker logs id + docker ps -aqf "ancestor=$DOCKER_TAG" | xargs -Iid docker logs id + exit 1 +fi + +revision=$(curl -m 5 http://localhost:3000/internal/revision || true) +if [ "${revision}" != "${GITHUB_SHA}" ]; then + echo "Internal revision test didn't pass." + docker ps -aqf "ancestor=$DOCKER_TAG" | xargs -Iid docker logs id exit 1 fi -rubygems_version_installed=$(docker run quay.io/$TRAVIS_REPO_SLUG:$TRAVIS_COMMIT -- gem -v) +rubygems_version_installed=$(docker run "$DOCKER_TAG" -- gem -v) -if [ $rubygems_version_installed != $RUBYGEMS_VERSION ]; then +if [ "$rubygems_version_installed" != "$RUBYGEMS_VERSION" ]; then echo "Installed gem version doesn't match" echo "expected: $RUBYGEMS_VERSION, found: $rubygems_version_installed" exit 1 fi -if [ -z "$DOCKER_USERNAME" ] || [ -z "$DOCKER_PASSWORD" ] -then +pusher_arn="arn:aws:iam::048268392960:role/rubygems-ecr-pusher" +caller_arn="$(aws sts get-caller-identity --output text --query Arn || true)" + +set +x +[[ "$caller_arn" == "$pusher_arn" ]] || + [[ "$caller_arn" == "arn:aws:sts::048268392960:assumed-role/rubygems-ecr-pusher/GitHubActions" ]] || + export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s" \ + $(aws sts assume-role \ + --role-arn "${pusher_arn}" \ + --role-session-name push-rubygems-docker-tag \ + --query "Credentials.[AccessKeyId,SecretAccessKey,SessionToken]" \ + --output text)) || + true +set -x + +if [[ -z "${AWS_SESSION_TOKEN}" ]]; then + echo "Skipping push since no AWS session token was found" exit 0 fi -echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin quay.io +docker push "$DOCKER_TAG" -docker push quay.io/$TRAVIS_REPO_SLUG:$TRAVIS_COMMIT +if [ -n "$GITHUB_STEP_SUMMARY" ]; then + echo -n "Pushed image \`$DOCKER_TAG\`\n" >>"$GITHUB_STEP_SUMMARY" +fi diff --git a/script/change_email b/script/change_email index c5529502ac7..453aaa36578 100755 --- a/script/change_email +++ b/script/change_email @@ -14,7 +14,7 @@ from_user.generate_confirmation_token if from_user.save puts "#{from_user.name}'s email is now: #{to_email}" - Delayed::Job.enqueue(EmailConfirmationMailer.new(from_user.id)) + Mailer.email_confirmation(from_user).deliver_later puts "enqueued email confirmation mail." else puts "could not save user" diff --git a/script/delayed_job b/script/delayed_job deleted file mode 100755 index 2eee7590408..00000000000 --- a/script/delayed_job +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env ruby - -require File.expand_path(File.join(File.dirname(__FILE__), "..", "config", "environment")) -require "delayed/command" -Delayed::Command.new(ARGV).daemonize diff --git a/script/dev b/script/dev new file mode 100755 index 00000000000..73576e5911e --- /dev/null +++ b/script/dev @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +export GITHUB_KEY="op://bq25xfqpafelzdxwqu3mdq6rza/zsq3oitss3rbo4njqpjfd5r5cm/GITHUB_KEY" +export GITHUB_SECRET="op://bq25xfqpafelzdxwqu3mdq6rza/zsq3oitss3rbo4njqpjfd5r5cm/GITHUB_SECRET" +export AVO_LICENSE_KEY="op://bq25xfqpafelzdxwqu3mdq6rza/netp2leghd7zqdxlkp5asy67d4/license key" +export DATADOG_CSP_API_KEY="op://bq25xfqpafelzdxwqu3mdq6rza/eisktguwm3pcfkwm7avqamdnxq/credential" +export HOOK_RELAY_ACCOUNT_ID="op://bq25xfqpafelzdxwqu3mdq6rza/s3fs2zznjssijxouugddmwnuma/username" +export HOOK_RELAY_HOOK_ID="op://bq25xfqpafelzdxwqu3mdq6rza/s3fs2zznjssijxouugddmwnuma/credential" +export HOOK_RELAY_API_KEY="op://bq25xfqpafelzdxwqu3mdq6rza/s3fs2zznjssijxouugddmwnuma/API Key" +export LAUNCH_DARKLY_SDK_KEY="op://bq25xfqpafelzdxwqu3mdq6rza/3o73k6fcenzrbspwcm4g4j5z2a/credential" + +exec op run --no-masking --account 5MS5DHTO5NH7BAHTSIGT5NOAIE -- "${@}" diff --git a/script/disable_mfa b/script/disable_mfa new file mode 100755 index 00000000000..e6a9c220bcb --- /dev/null +++ b/script/disable_mfa @@ -0,0 +1,25 @@ +#!/usr/bin/env ruby + +if ARGV.empty? + puts "Disables MFA and resets password of the user account." + puts "USAGE: script/disable_mfa USERNAME|EMAIL" + exit +end + +name = ARGV.first +puts "Disabling MFA for user #{name}..." + +ENV["RAILS_ENV"] ||= "production" +require_relative "../config/environment" + +begin + user = User.find_by_name(name) + puts "Found user: #{user.handle} #{user.email}" + user.disable_totp! + user.password = SecureRandom.hex(20).encode("UTF-8") + user.save! + puts "Done." +rescue ActiveRecord::RecordNotFound + puts "User #{name} not found." + exit 1 # return non-zero on fail +end diff --git a/script/install_toxiproxy.sh b/script/install_toxiproxy.sh deleted file mode 100755 index 151ee8eda4e..00000000000 --- a/script/install_toxiproxy.sh +++ /dev/null @@ -1,33 +0,0 @@ -set -e - -if which toxiproxy > /dev/null; then - echo "Toxiproxy is already installed." - exit 0 -fi - -if [ "$CI" == "true" ]; then - echo "Installing toxiproxy binary for Travis CI" - wget -O /tmp/toxiproxy-1.2.0 https://github.com/Shopify/toxiproxy/releases/download/v1.2.0/toxiproxy-linux-amd64 - chmod +x /tmp/toxiproxy-1.2.0 - /tmp/toxiproxy-1.2.0 >& /dev/null & - exit 0 -fi - -if which apt-get > /dev/null; then - echo "Installing toxiproxy-1.2.0.deb" - wget -O /tmp/toxiproxy-1.2.0.deb https://github.com/Shopify/toxiproxy/releases/download/v1.2.0/toxiproxy_1.2.0_amd64.deb - sudo dpkg -i /tmp/toxiproxy-1.2.0.deb - sudo service toxiproxy start - exit 0 -fi - -if which brew > /dev/null; then - echo "Installing toxiproxy from homebrew." - brew tap shopify/shopify - brew install toxiproxy - brew info toxiproxy - exit 0 -fi - -echo "Sorry, there is no toxiproxy package available for your system. You might need to build it from source." -exit 1 diff --git a/script/load-pg-dump b/script/load-pg-dump index 229106490ff..b5d971e1dc1 100755 --- a/script/load-pg-dump +++ b/script/load-pg-dump @@ -23,6 +23,7 @@ pg_database=rubygems pg_user=postgres download=false host= +port= ## For downloading base_url="https://s3-us-west-2.amazonaws.com/rubygems-dumps/" @@ -40,13 +41,16 @@ Load a rubygems.org postgresql dump into a datatbase. -d DATABASE load the data into this database (default: rubygems) -u USER connect to postgresql using this username (default: postgres) -H HOSTNAME connect to postgresql using this hostname (default: Unix-domain socket) + -p PORT connect to postgresql using this port (default: 5432) -Example: ./load-pg-dump -d rubygems_development ~/Downloads/public_postgresql.tar +Examples: +Load already downloaded tar: ./load-pg-dump -d rubygems_development ~/Downloads/public_postgresql.tar +Download and then load most recent dump: ./load-pg-dump -d rubygems_development -c ~/Downloads/public_postgresql.tar EOF } OPTIND=1 -while getopts "hcd:u:H:" opt; do +while getopts "hcd:u:H:p:" opt; do case "$opt" in h) show_help @@ -60,6 +64,8 @@ while getopts "hcd:u:H:" opt; do ;; H) host=$OPTARG ;; + p) port=$OPTARG + ;; '?') show_help >&2 exit 1 @@ -72,7 +78,9 @@ pg_host= if [ -n "$host" ]; then pg_host="-h $host" fi - +if [ -n "$port" ]; then + pg_host="${pg_host} -p $port" +fi public_tar=$1 if [ -z "$public_tar" ]; then @@ -89,7 +97,7 @@ fi printf 'Loading "%s" into database "%s" as user "%s"\n', "$public_tar", "$pg_database", "$pg_user" -echo "Droppping database $pg_database" +echo "Dropping database $pg_database" dropdb $pg_host -U $pg_user $pg_database echo "Creating database $pg_database" @@ -101,7 +109,7 @@ psql -q $pg_host -U $pg_user -d $pg_database -c "CREATE EXTENSION IF NOT EXISTS echo "Running migrations" rake db:migrate -echo "Droppping tables in $pg_database that we're going to load in" +echo "Dropping tables in $pg_database that we're going to load in" psql -q $pg_host -U $pg_user -d $pg_database -c "DROP TABLE IF EXISTS dependencies, gem_downloads, linksets, rubygems, versions CASCADE;" # Extract the single PostgresSQL.sql.gz file from the tar file, pass it through gunzip diff --git a/script/permadelete b/script/permadelete index f4fc0069159..bb9b08a73d4 100755 --- a/script/permadelete +++ b/script/permadelete @@ -9,13 +9,11 @@ abort "Usage: #{$PROGRAM_NAME} [GEM_NAME=hola] [VERSION=all] [AS=email]" if [nam rubygem = Rubygem.find_by_name!(name) -versions = begin - if number == "all" - rubygem.versions - else - [rubygem.versions.find_by_number!(number)] - end -end +versions = if number == "all" + rubygem.versions + else + [rubygem.versions.find_by_number!(number)] + end owner = User.find_by_email!(email) diff --git a/script/rails b/script/rails deleted file mode 100755 index 5c0bdc51d03..00000000000 --- a/script/rails +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env ruby - -APP_PATH = File.expand_path("../config/application", __dir__) -require File.expand_path("../config/boot", __dir__) -require "rails/commands" diff --git a/script/release_reserved_namespace b/script/release_reserved_namespace index 5f3c1206a54..eb8415eb89b 100755 --- a/script/release_reserved_namespace +++ b/script/release_reserved_namespace @@ -8,6 +8,6 @@ ENV["RAILS_ENV"] ||= "production" require_relative "../config/environment" rubygem = Rubygem.find_by_name!(rubygem_name) -rubygem.update_attribute(:updated_at, 101.days.ago) +rubygem.release_reserved_namespace! puts "#{rubygem_name} was released." diff --git a/script/restore_version b/script/restore_version index cf96a271ee8..24faf59a07a 100755 --- a/script/restore_version +++ b/script/restore_version @@ -10,7 +10,7 @@ require_relative "../config/environment" rubygem = Rubygem.find_by_name!(gem_name) slug = version_number slug << "-#{platform}" if platform.present? -version = Version.find_from_slug!(rubygem, slug) +version = rubygem.find_version!(number: version_number, platform: platform) raise "Version #{slug} for #{gem_name} was not found" unless version deletion = Deletion.find_by(rubygem: gem_name, number: version_number, platform: version.platform) diff --git a/script/s3_utils.rb b/script/s3_utils.rb index 62a1f771000..19b9284f1df 100644 --- a/script/s3_utils.rb +++ b/script/s3_utils.rb @@ -19,17 +19,17 @@ def calculate_s3_versions_ts_deltas(versions) def md5_compare_s3_versions(version_full_name) @s3.list_object_versions(bucket: @bucket, prefix: "gems/#{version_full_name}.gem").versions.map do |vs3| - body = @s3.get_object(key: vs3.key, bucket: @bucket, version_id: vs3.version_id).body.read + body = @s3.get_object(key: vs3.key, bucket: @bucket, version_id: vs3.version_id).body.read Digest::MD5.hexdigest(body) end end def write_s3_versions(version_full_name) @s3.list_object_versions(bucket: @bucket, prefix: "gems/#{version_full_name}.gem").versions.map do |vs3| - body = @s3.get_object(key: vs3.key, bucket: @bucket, version_id: vs3.version_id).body.read + body = @s3.get_object(key: vs3.key, bucket: @bucket, version_id: vs3.version_id).body.read md5 = Digest::MD5.hexdigest(body) filename = "#{version_full_name}-#{md5[0..5]}.gem" - File.open(filename, "w") { |file| file.write(body) } + File.write(filename, body) filename end end diff --git a/script/setup b/script/setup deleted file mode 100755 index f81ee6dc8ec..00000000000 --- a/script/setup +++ /dev/null @@ -1,40 +0,0 @@ -#!/bin/sh -set -e -export PATH="$(dirname "$0")/../bin:$PATH" # Use binstubs - -if [ "$1" = "-v" ]; then - exec 3>&1 -else - exec 3>/dev/null -fi - -if [ ! -f config/database.yml ]; then - echo "$0: Copying database.yml.example over" - cp config/database.yml.example config/database.yml -fi - -# Wipe out logs and scss cache. -echo "$0: Cleaning out old logs" -{ rm -f log/* -} >&3 2>&1 - -# Install dependencies -echo "$0: Installing libraries and plugins" -{ gem list -i bundler || gem install bundler - bundle - script/install_toxiproxy.sh -} >&3 2>&1 - -# Wipe and load the database unless KEEPDB=1 env var is set. -if [ -z "$KEEPDB" ]; then - echo "$0: Reloading the database" - { rake db:create:all db:drop:all db:setup --trace - rake db:test:prepare --trace - } >&3 2>&1 -fi - -echo "$0: Restarting Pow/Passenger" -mkdir -p tmp -touch tmp/restart.txt - -echo "$0: Done!" diff --git a/script/update-rubygems b/script/update-rubygems new file mode 100755 index 00000000000..b71245fc401 --- /dev/null +++ b/script/update-rubygems @@ -0,0 +1,77 @@ +#!/usr/bin/env ruby + +unless ARGV.empty? + puts "Usage: #{$PROGRAM_NAME}" + exit 1 +end + +require "bundler" +require "yaml" + +return unless (bundler_version = Bundler.self_manager.send(:resolve_update_version_from, ">= 0")&.version) +rubygems_version = Gem::Version.new(bundler_version.segments.tap { |s| s[0] += 1 }.join(".")) + +def order_nodes(nodes) + methods = %i[start_line start_column end_line end_column] + nodes.sort_by { |node| methods.map { |k| node.send(k) } }.reverse +end + +def find_nodes(node, path) # rubocop:disable Metrics + return [node] if path.empty? + head, *tail = path + + raise "Expected to index #{path}, got #{node}" if node.scalar? + + if head == "*" + if node.sequence? + return node.children.flat_map { |child| find_nodes(child, tail) }.compact + elsif node.mapping? + return node.children.each_slice(2).flat_map { |_, v| find_nodes(v, tail) }.compact + else + raise "Expected to index #{path}, got #{node}" + end + end + + if node.document? + find_nodes(node.root, path) + elsif node.sequence? + head, expected_value = head.split("=", 2) + if expected_value + node.to_ruby.each_with_index.select { |h, _| h[head] == expected_value }.map(&:last).flat_map { |i| find_nodes(node.children[i], tail) } + else + find_nodes(node.children[head.to_i], tail) + end + elsif node.mapping? + node.children.each_slice(2).flat_map { |k, v| find_nodes(v, tail) if k.value == head && (!expected_value || expected_value == k.value) }.compact + else + raise "Expected to index #{path}, got #{node}" + end +end + +def sub_yaml(file, path, value) + nodes = find_nodes YAML.parse_file(file), path + contents = File.read(file) + lines = contents.lines + order_nodes(nodes).each do |node| + raise "Expected single line node, got #{node}" if node.start_line != node.end_line + line = lines[node.start_line] + range = node.start_column..node.end_column.pred + raise "Expected range to be #{node.value.inspect}, is #{line[range].inspect}" unless YAML.load(line[range]) == node.value + line[range] = value + end + File.write(file, lines.join) +end + +sub_yaml ".github/workflows/docker.yml", %w[jobs * env RUBYGEMS_VERSION], rubygems_version.to_s.inspect +sub_yaml ".github/workflows/test.yml", %w[jobs * strategy matrix rubygems 0 version], rubygems_version.to_s.inspect + +ruby_version = File.read(".ruby-version").strip + +%w[Dockerfile .devcontainer/Dockerfile].each do |f| + File.write(f, File.read(f).sub(/(RUBY_VERSION=)[\d.]+/, "\\1#{ruby_version}")) +end + +sub_yaml ".github/workflows/docker.yml", %w[jobs * env RUBY_VERSION], ruby_version.inspect +sub_yaml ".github/workflows/test.yml", %w[jobs * strategy matrix ruby_version 0], ruby_version.inspect + +system("bundle", "update", "--bundler=#{bundler_version}", exception: true) diff --git a/script/yank_gem b/script/yank_gem index ae18f7b29c6..ef694bc4231 100755 --- a/script/yank_gem +++ b/script/yank_gem @@ -16,7 +16,7 @@ rubygem = Rubygem.where(name: gemname).first to_yank = version ? rubygem.versions.where(number: version) : rubygem.versions -user = User.where(email: "security@rubygems.org").first +user = User.security_user puts "Yanking #{rubygem.name}" to_yank.each do |v| diff --git a/script/yank_user b/script/yank_user index 87a83228f6f..f3ee8e1b9f2 100755 --- a/script/yank_user +++ b/script/yank_user @@ -14,7 +14,7 @@ require_relative "../config/environment" user = User.find_by!(handle: handle) -security_user = User.find_by!(email: "security@rubygems.org") +security_user = User.security_user user.rubygems.each do |rubygem| puts "Yanking #{rubygem.name}" diff --git a/shipit.yml b/shipit.yml index 082443b6bb1..844763ba76f 100644 --- a/shipit.yml +++ b/shipit.yml @@ -8,15 +8,39 @@ dependencies: deploy: override: - - kubernetes-deploy rubygems-$ENVIRONMENT rubygems --template-dir config/deploy/$ENVIRONMENT --bindings=environment=$ENVIRONMENT + - krane render -f config/deploy/$ENVIRONMENT --bindings=environment=$ENVIRONMENT --current-sha=$REVISION | krane deploy rubygems-$ENVIRONMENT rubygems --stdin -f config/deploy/$ENVIRONMENT/secrets.ejson rollback: override: - - kubernetes-deploy rubygems-$ENVIRONMENT rubygems --template-dir config/deploy/$ENVIRONMENT --bindings=environment=$ENVIRONMENT + - krane render -f config/deploy/$ENVIRONMENT --bindings=environment=$ENVIRONMENT --current-sha=$REVISION | krane deploy rubygems-$ENVIRONMENT rubygems --stdin -f config/deploy/$ENVIRONMENT/secrets.ejson tasks: restart: action: Restart application - description: Trigger the restart of both unicorn and background workers + description: Trigger the restart of both puma and background workers steps: - - kubernetes-restart rubygems-$ENVIRONMENT rubygems + - krane restart rubygems-$ENVIRONMENT rubygems + autoscale_web: + action: Autoscale web deployment + description: "Autoscale the web deployment to %{MIN}-%{MAX} replicas" + steps: + - kubectl autoscale -n rubygems-$ENVIRONMENT --context rubygems deployment/web --min="$MIN" --max="$MAX" --cpu-percentage="$CPU_PERCENTAGE" + variables: + - name: MIN + title: The minimum number of web replicas + default: 3 + - name: MAX + title: The maximum number of web replicas + default: 10 + - name: CPU_PERCENTAGE + title: The threshold of CPU utilization used for autoscaling + default: 75 + scale_jobs: + action: Scale jobs deployment + description: "Scale the jobs deployment to %{REPLICAS}" + steps: + - kubectl scale -n rubygems-$ENVIRONMENT --context rubygems deployment/jobs --replicas="$REPLICAS" + variables: + - name: REPLICAS + title: The number of jobs replicas to scale to + default: 5 diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb new file mode 100644 index 00000000000..f0eb3df30d6 --- /dev/null +++ b/test/application_system_test_case.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class ApplicationSystemTestCase < ActionDispatch::SystemTestCase + include OauthHelpers + include AvoHelpers + driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400] +end diff --git a/test/certs/chain.pem b/test/certs/chain.pem new file mode 100644 index 00000000000..624785d326a --- /dev/null +++ b/test/certs/chain.pem @@ -0,0 +1,56 @@ +-----BEGIN CERTIFICATE----- +MIIFKTCCBBGgAwIBAgISBFspP+tJfRaC6xprreB4Rp9KMA0GCSqGSIb3DQEBCwUA +MDIxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQD +EwJSMzAeFw0yMTA0MTcwMjQzMTlaFw0yMTA3MTYwMjQzMTlaMBwxGjAYBgNVBAMT +EXd3dy5jb2Rlb3Rha3UuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC +AQEAx6h5vNPfkkrtYWxn1PWDDLRAwrGmZbkYPttjHBRSwTcd7rsIX4PcSzw9fWxm +K4vIkAYoKAElIvsSE3xRUjyzMrACfdhK5J8rG25fq94iVyoYaNBQV0WMJkO6X47s +hGeIKkK91ohR5b2tMw3/z9zELP0TVo2TPG7rYsBZm34myldqDA8yVEBEOa+Qdpda +9xewPhkkdpAU55qgWTrD21m7vGq9WpsBz4wNKnwVsaugtkRH82VPIfaL4ZI9kox6 +QoPWe/tHUBdlDkuT7ud77eLAWnC/5Clg28/9GU/Z8Nj8SrrKuXL6WUXmxxaAhWUR +Qx4VblZeuIpwd0nHyP0hz4CWKQIDAQABo4ICTTCCAkkwDgYDVR0PAQH/BAQDAgWg +MB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB0G +A1UdDgQWBBTKiSGZuLFSIG2JPbFSZa9TxMu5WTAfBgNVHSMEGDAWgBQULrMXt1hW +y65QCUDmH6+dixTCxjBVBggrBgEFBQcBAQRJMEcwIQYIKwYBBQUHMAGGFWh0dHA6 +Ly9yMy5vLmxlbmNyLm9yZzAiBggrBgEFBQcwAoYWaHR0cDovL3IzLmkubGVuY3Iu +b3JnLzAcBgNVHREEFTATghF3d3cuY29kZW90YWt1LmNvbTBMBgNVHSAERTBDMAgG +BmeBDAECATA3BgsrBgEEAYLfEwEBATAoMCYGCCsGAQUFBwIBFhpodHRwOi8vY3Bz +LmxldHNlbmNyeXB0Lm9yZzCCAQUGCisGAQQB1nkCBAIEgfYEgfMA8QB3AJQgvB6O +1Y1siHMfgosiLA3R2k1ebE+UPWHbTi9YTaLCAAABeN3s/lgAAAQDAEgwRgIhAKFY +Q+vBe3zyeBazxp8kVN7oLvcQ6Y9PPz199tVhYnEbAiEAhU/xdbQaY/6b93h+7NTF +sPG7X4lq/3UoNgoXcAVGZgoAdgD2XJQv0XcwIhRUGAgwlFaO400TGTO/3wwvIAvM +TvFk4wAAAXjd7P5OAAAEAwBHMEUCIQDWd79+jWaGuf3acm5/yV95jL2KvzeGFfdU +HZlKIeWFmAIgDSZ6ug7AyhYNKjzFV4ZSICln+L4yI92EpOa+8gDG6/0wDQYJKoZI +hvcNAQELBQADggEBAHIhMYm06lLFmJL+cfIg5fFEmFNdHmmZn88Hypv4/MtmqTKv +5asF/z3TvhW4hX2+TY+NdcqGT7cZFo/ZF/tS6oBXPgmBYM1dEfp2FAdnGNOySC5Y +7RC4Uk9TUpP2g101YBmj6dQKQluAwIQk+gO4MSlHE0J0U/lMpjvrLWcuHbV4/xWJ +IdM+iPq8GeYt5epYmNc7XeRIgv7V3RxDQdBv2OVM5mtPVerdiO0ISrdbe5mvz2+Z +rhSg+EJNHlmMwcq5HqtMwS8M8Ax+vLmWCOkPWXhyV8wQaQcFjZJfpIGUvCnMTqsh +kSIYXq2CbSDUUFRFssNN6EdVms0KnmW3BUu0xAk= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIEZTCCA02gAwIBAgIQQAF1BIMUpMghjISpDBbN3zANBgkqhkiG9w0BAQsFADA/ +MSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT +DkRTVCBSb290IENBIFgzMB4XDTIwMTAwNzE5MjE0MFoXDTIxMDkyOTE5MjE0MFow +MjELMAkGA1UEBhMCVVMxFjAUBgNVBAoTDUxldCdzIEVuY3J5cHQxCzAJBgNVBAMT +AlIzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuwIVKMz2oJTTDxLs +jVWSw/iC8ZmmekKIp10mqrUrucVMsa+Oa/l1yKPXD0eUFFU1V4yeqKI5GfWCPEKp +Tm71O8Mu243AsFzzWTjn7c9p8FoLG77AlCQlh/o3cbMT5xys4Zvv2+Q7RVJFlqnB +U840yFLuta7tj95gcOKlVKu2bQ6XpUA0ayvTvGbrZjR8+muLj1cpmfgwF126cm/7 +gcWt0oZYPRfH5wm78Sv3htzB2nFd1EbjzK0lwYi8YGd1ZrPxGPeiXOZT/zqItkel +/xMY6pgJdz+dU/nPAeX1pnAXFK9jpP+Zs5Od3FOnBv5IhR2haa4ldbsTzFID9e1R +oYvbFQIDAQABo4IBaDCCAWQwEgYDVR0TAQH/BAgwBgEB/wIBADAOBgNVHQ8BAf8E +BAMCAYYwSwYIKwYBBQUHAQEEPzA9MDsGCCsGAQUFBzAChi9odHRwOi8vYXBwcy5p +ZGVudHJ1c3QuY29tL3Jvb3RzL2RzdHJvb3RjYXgzLnA3YzAfBgNVHSMEGDAWgBTE +p7Gkeyxx+tvhS5B1/8QVYIWJEDBUBgNVHSAETTBLMAgGBmeBDAECATA/BgsrBgEE +AYLfEwEBATAwMC4GCCsGAQUFBwIBFiJodHRwOi8vY3BzLnJvb3QteDEubGV0c2Vu +Y3J5cHQub3JnMDwGA1UdHwQ1MDMwMaAvoC2GK2h0dHA6Ly9jcmwuaWRlbnRydXN0 +LmNvbS9EU1RST09UQ0FYM0NSTC5jcmwwHQYDVR0OBBYEFBQusxe3WFbLrlAJQOYf +r52LFMLGMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjANBgkqhkiG9w0B +AQsFAAOCAQEA2UzgyfWEiDcx27sT4rP8i2tiEmxYt0l+PAK3qB8oYevO4C5z70kH +ejWEHx2taPDY/laBL21/WKZuNTYQHHPD5b1tXgHXbnL7KqC401dk5VvCadTQsvd8 +S8MXjohyc9z9/G2948kLjmE6Flh9dDYrVYA9x2O+hEPGOaEOa1eePynBgPayvUfL +qjBstzLhWVQLGAkXXmNs+5ZnPBxzDJOLxhF2JIbeQAcH5H0tZrUlo5ZYyOqA7s9p +O5b85o3AM/OJ+CktFBQtfvBhcJVd9wvlwPsk+uyOy2HI7mNxKKgsBTt375teA2Tw +UdHkhVNcsAKX1H7GNNLOEADksd86wuoXvg== +-----END CERTIFICATE----- diff --git a/test/components/preview_test.rb b/test/components/preview_test.rb new file mode 100644 index 00000000000..c7c7de455d6 --- /dev/null +++ b/test/components/preview_test.rb @@ -0,0 +1,26 @@ +require "test_helper" + +class PreviewTest < ComponentTest + attr_reader :current_user + + def view_context + super.tap do |view_context| + test_case = self + view_context.singleton_class.define_method(:current_user) { test_case.current_user } + end + end + + test "all previews render" do + capture_io { Rails.application.load_seed } + + aggregate_assertions do + Lookbook::Engine.previews.each do |preview| + preview.scenarios.each do |scenario| + refute_nil preview(preview.lookup_path, scenario: scenario.name) + rescue StandardError => e + AggregateAssertions::AssertionAggregator.add_error(Minitest::UnexpectedError.new(e)) + end + end + end + end +end diff --git a/test/components/previews/events/rubygem_event/owner/added_component_preview.rb b/test/components/previews/events/rubygem_event/owner/added_component_preview.rb new file mode 100644 index 00000000000..3f94c1ce86a --- /dev/null +++ b/test/components/previews/events/rubygem_event/owner/added_component_preview.rb @@ -0,0 +1,39 @@ +class Events::RubygemEvent::Owner::AddedComponentPreview < Lookbook::Preview + # @param owner text + # @param authorizer text + def default(owner: "Owner", authorizer: "Authorizer", user: User.first!) + event = FactoryBot.build(:events_rubygem_event, tag: Events::RubygemEvent::OWNER_ADDED, additional: + { + owner:, + owner_gid: user.to_gid.to_s, + authorizer:, + actor_gid: user.to_gid.to_s + }) + render Events::RubygemEvent::Owner::AddedComponent.new(event:) + end + + # @param owner text + # @param authorizer text + def without_actor(owner: "Owner", authorizer: "Authorizer", user: User.first!) + event = FactoryBot.build(:events_rubygem_event, + tag: Events::RubygemEvent::OWNER_ADDED, + additional: + { + owner:, + owner_gid: user.to_gid.to_s, + authorizer: + }) + render Events::RubygemEvent::Owner::AddedComponent.new(event:) + end + + # @param owner text + def without_authorizer(owner: "Owner", user: User.first!) + event = FactoryBot.build(:events_rubygem_event, tag: Events::RubygemEvent::OWNER_ADDED, additional: + { + owner:, + owner_gid: user.to_gid.to_s, + actor_gid: user.to_gid.to_s + }) + render Events::RubygemEvent::Owner::AddedComponent.new(event:) + end +end diff --git a/test/components/previews/events/rubygem_event/owner/confirmed_component_preview.rb b/test/components/previews/events/rubygem_event/owner/confirmed_component_preview.rb new file mode 100644 index 00000000000..3c3ab714d49 --- /dev/null +++ b/test/components/previews/events/rubygem_event/owner/confirmed_component_preview.rb @@ -0,0 +1,14 @@ +class Events::RubygemEvent::Owner::ConfirmedComponentPreview < Lookbook::Preview + # @param owner text + def default(owner: "Owner", user: User.first!) + event = FactoryBot.build(:events_rubygem_event, tag: Events::RubygemEvent::OWNER_CONFIRMED, additional: + { + owner:, + owner_gid: user.to_gid.to_s, + actor_gid: user.to_gid.to_s + }) + render Events::RubygemEvent::Owner::ConfirmedComponent.new( + event: + ) + end +end diff --git a/test/components/previews/events/rubygem_event/owner/removed_component_preview.rb b/test/components/previews/events/rubygem_event/owner/removed_component_preview.rb new file mode 100644 index 00000000000..bd016e3e823 --- /dev/null +++ b/test/components/previews/events/rubygem_event/owner/removed_component_preview.rb @@ -0,0 +1,14 @@ +class Events::RubygemEvent::Owner::RemovedComponentPreview < Lookbook::Preview + # @param owner text + def default(owner: "Owner", user: User.first!) + event = FactoryBot.build(:events_rubygem_event, tag: Events::RubygemEvent::OWNER_REMOVED, additional: + { + owner:, + owner_gid: user.to_gid.to_s, + actor_gid: user.to_gid.to_s + }) + render Events::RubygemEvent::Owner::RemovedComponent.new( + event: + ) + end +end diff --git a/test/components/previews/events/rubygem_event/version/pushed_component_preview.rb b/test/components/previews/events/rubygem_event/version/pushed_component_preview.rb new file mode 100644 index 00000000000..2615ea62355 --- /dev/null +++ b/test/components/previews/events/rubygem_event/version/pushed_component_preview.rb @@ -0,0 +1,19 @@ +class Events::RubygemEvent::Version::PushedComponentPreview < Lookbook::Preview + def default(rubygem: Rubygem.first!, # rubocop:disable Metrics/ParameterLists + number: "1.0.0", platform: "ruby", + version_gid: rubygem.versions.where(number:, platform:).first&.to_gid, + pushed_by: "Pusher", actor_gid: version_gid&.find&.pusher&.to_gid) + event = FactoryBot.build(:events_rubygem_event, rubygem:, tag: Events::RubygemEvent::VERSION_PUSHED, additional: + { + number:, + platform:, + pushed_by:, + + version_gid:, + actor_gid: + }) + render Events::RubygemEvent::Version::PushedComponent.new( + event: + ) + end +end diff --git a/test/components/previews/events/rubygem_event/version/unyanked_component_preview.rb b/test/components/previews/events/rubygem_event/version/unyanked_component_preview.rb new file mode 100644 index 00000000000..045a48317a3 --- /dev/null +++ b/test/components/previews/events/rubygem_event/version/unyanked_component_preview.rb @@ -0,0 +1,20 @@ +class Events::RubygemEvent::Version::UnyankedComponentPreview < Lookbook::Preview + # @param number text + # @param platform text + def default(rubygem: Rubygem.first!, number: "1.0.0", platform: "ruby", version_gid: rubygem.versions.where(number:, platform:).first&.to_gid) + event = FactoryBot.build(:events_rubygem_event, rubygem:, tag: Events::RubygemEvent::VERSION_UNYANKED, additional: + { + number:, + platform:, + + version_gid: + }) + render Events::RubygemEvent::Version::UnyankedComponent.new( + event: + ) + end + + def without_version_gid(**) + default(**, version_gid: nil) + end +end diff --git a/test/components/previews/events/rubygem_event/version/yank_forbidden_component_preview.rb b/test/components/previews/events/rubygem_event/version/yank_forbidden_component_preview.rb new file mode 100644 index 00000000000..f078073b263 --- /dev/null +++ b/test/components/previews/events/rubygem_event/version/yank_forbidden_component_preview.rb @@ -0,0 +1,21 @@ +class Events::RubygemEvent::Version::YankForbiddenComponentPreview < Lookbook::Preview + def default(rubygem: Rubygem.first!, # rubocop:disable Metrics/ParameterLists + reason: "Versions used for testing can't be yanked.", + number: "0.0.1", platform: "ruby", + version_gid: rubygem.versions.where(number:, platform:).first&.to_gid, + yanked_by: "Yanker", actor_gid: version_gid&.find&.yanker&.to_gid) + event = FactoryBot.build(:events_rubygem_event, rubygem:, tag: Events::RubygemEvent::VERSION_YANK_FORBIDDEN, additional: + { + number:, + platform:, + yanked_by:, + + version_gid:, + actor_gid:, + reason: + }) + render Events::RubygemEvent::Version::YankForbiddenComponent.new( + event: + ) + end +end diff --git a/test/components/previews/events/rubygem_event/version/yanked_component_preview.rb b/test/components/previews/events/rubygem_event/version/yanked_component_preview.rb new file mode 100644 index 00000000000..acbca8c9388 --- /dev/null +++ b/test/components/previews/events/rubygem_event/version/yanked_component_preview.rb @@ -0,0 +1,19 @@ +class Events::RubygemEvent::Version::YankedComponentPreview < Lookbook::Preview + def default(rubygem: Rubygem.first!, # rubocop:disable Metrics/ParameterLists + number: "0.0.1", platform: "ruby", + version_gid: rubygem.versions.where(number:, platform:).first&.to_gid, + yanked_by: "Yanker", actor_gid: version_gid&.find&.yanker&.to_gid) + event = FactoryBot.build(:events_rubygem_event, rubygem:, tag: Events::RubygemEvent::VERSION_YANKED, additional: + { + number:, + platform:, + yanked_by:, + + version_gid:, + actor_gid: + }) + render Events::RubygemEvent::Version::YankedComponent.new( + event: + ) + end +end diff --git a/test/components/previews/events/user_event/api_key/created_component_preview.rb b/test/components/previews/events/user_event/api_key/created_component_preview.rb new file mode 100644 index 00000000000..481d78686fd --- /dev/null +++ b/test/components/previews/events/user_event/api_key/created_component_preview.rb @@ -0,0 +1,18 @@ +class Events::UserEvent::ApiKey::CreatedComponentPreview < Lookbook::Preview + # @param name text + # @param scopes [Array] + # @param mfa toggle + # @param gem text + def default(name: "example", scopes: ["push"], mfa: false, gem: nil) + event = FactoryBot.build(:events_user_event, tag: Events::UserEvent::API_KEY_CREATED, additional: + { + name:, + scopes:, + mfa:, + gem: gem.presence + }) + render Events::UserEvent::ApiKey::CreatedComponent.new( + event: + ) + end +end diff --git a/test/components/previews/events/user_event/api_key/deleted_component_preview.rb b/test/components/previews/events/user_event/api_key/deleted_component_preview.rb new file mode 100644 index 00000000000..6db2a682487 --- /dev/null +++ b/test/components/previews/events/user_event/api_key/deleted_component_preview.rb @@ -0,0 +1,12 @@ +class Events::UserEvent::ApiKey::DeletedComponentPreview < Lookbook::Preview + # @param name text + def default(name: "example") + event = FactoryBot.build(:events_user_event, tag: Events::UserEvent::API_KEY_DELETED, additional: + { + name: + }) + render Events::UserEvent::ApiKey::DeletedComponent.new( + event: + ) + end +end diff --git a/test/components/previews/events/user_event/email/added_component_preview.rb b/test/components/previews/events/user_event/email/added_component_preview.rb new file mode 100644 index 00000000000..ede8ab665d2 --- /dev/null +++ b/test/components/previews/events/user_event/email/added_component_preview.rb @@ -0,0 +1,12 @@ +class Events::UserEvent::Email::AddedComponentPreview < Lookbook::Preview + # @param email email + def default(email: "user@example.com") + event = FactoryBot.build(:events_user_event, tag: Events::UserEvent::EMAIL_ADDED, additional: + { + email: + }) + render Events::UserEvent::Email::AddedComponent.new( + event: + ) + end +end diff --git a/test/components/previews/events/user_event/email/sent_component_preview.rb b/test/components/previews/events/user_event/email/sent_component_preview.rb new file mode 100644 index 00000000000..a6104202155 --- /dev/null +++ b/test/components/previews/events/user_event/email/sent_component_preview.rb @@ -0,0 +1,16 @@ +class Events::UserEvent::Email::SentComponentPreview < Lookbook::Preview + # @param subject text + # @param from email + # @param to email + def default(subject: "[Subject]", from: "example@rubygems.org", to: "user@example.com") + event = FactoryBot.build(:events_user_event, tag: Events::UserEvent::EMAIL_SENT, additional: + { + subject:, + from:, + to: + }) + render Events::UserEvent::Email::SentComponent.new( + event: + ) + end +end diff --git a/test/components/previews/events/user_event/email/verified_component_preview.rb b/test/components/previews/events/user_event/email/verified_component_preview.rb new file mode 100644 index 00000000000..93a017168d4 --- /dev/null +++ b/test/components/previews/events/user_event/email/verified_component_preview.rb @@ -0,0 +1,12 @@ +class Events::UserEvent::Email::VerifiedComponentPreview < Lookbook::Preview + # @param email email + def default(email: "user@example.com") + event = FactoryBot.build(:events_user_event, tag: Events::UserEvent::EMAIL_VERIFIED, additional: + { + email: + }) + render Events::UserEvent::Email::VerifiedComponent.new( + event: + ) + end +end diff --git a/test/components/previews/events/user_event/login/success_component_preview.rb b/test/components/previews/events/user_event/login/success_component_preview.rb new file mode 100644 index 00000000000..541d25d301b --- /dev/null +++ b/test/components/previews/events/user_event/login/success_component_preview.rb @@ -0,0 +1,30 @@ +class Events::UserEvent::Login::SuccessComponentPreview < Lookbook::Preview + def password + event = build_event( + authentication_method: "password" + ) + render Events::UserEvent::Login::SuccessComponent.new(event:) + end + + def webauthn + event = build_event( + authentication_method: "webauthn", + two_factor_label: "1Password" + ) + render Events::UserEvent::Login::SuccessComponent.new(event:) + end + + def password_with_otp + event = build_event( + authentication_method: "password", + two_factor_method: "otp" + ) + render Events::UserEvent::Login::SuccessComponent.new(event:) + end + + private + + def build_event(**additional) + FactoryBot.build(:events_user_event, tag: Events::UserEvent::LOGIN_SUCCESS, additional:) + end +end diff --git a/test/components/previews/events/user_event/user/created_component_preview.rb b/test/components/previews/events/user_event/user/created_component_preview.rb new file mode 100644 index 00000000000..d479ca020ac --- /dev/null +++ b/test/components/previews/events/user_event/user/created_component_preview.rb @@ -0,0 +1,12 @@ +class Events::UserEvent::User::CreatedComponentPreview < Lookbook::Preview + # @param email email + def default(email: "user@example.com") + event = FactoryBot.build(:events_user_event, tag: Events::UserEvent::CREATED, additional: + { + email: + }) + render Events::UserEvent::User::CreatedComponent.new( + event: + ) + end +end diff --git a/test/components/previews/oidc/api_key_role/table_component_preview.rb b/test/components/previews/oidc/api_key_role/table_component_preview.rb new file mode 100644 index 00000000000..b96a26f9265 --- /dev/null +++ b/test/components/previews/oidc/api_key_role/table_component_preview.rb @@ -0,0 +1,7 @@ +class OIDC::ApiKeyRole::TableComponentPreview < Lookbook::Preview + def default(api_key_roles: OIDC::ApiKeyRole.limit(3)) + render OIDC::ApiKeyRole::TableComponent.new( + api_key_roles: + ) + end +end diff --git a/test/components/previews/oidc/id_token/key_value_pairs_component_preview.rb b/test/components/previews/oidc/id_token/key_value_pairs_component_preview.rb new file mode 100644 index 00000000000..c0fea348c80 --- /dev/null +++ b/test/components/previews/oidc/id_token/key_value_pairs_component_preview.rb @@ -0,0 +1,19 @@ +class OIDC::IdToken::KeyValuePairsComponentPreview < Lookbook::Preview + # @param key text + # @param value text + def default(key: "key", value: "value") + pairs = { + "sub" => "1234567890", + "name" => "John Doe", + "given_name" => "John", + "family_name" => "Doe", + "preferred_username" => "johndoe", + key => value + } + render OIDC::IdToken::KeyValuePairsComponent.new(pairs:) + end + + def empty + render OIDC::IdToken::KeyValuePairsComponent.new(pairs: {}) + end +end diff --git a/test/components/previews/oidc/id_token/table_component_preview.rb b/test/components/previews/oidc/id_token/table_component_preview.rb new file mode 100644 index 00000000000..7d72cdfcc07 --- /dev/null +++ b/test/components/previews/oidc/id_token/table_component_preview.rb @@ -0,0 +1,7 @@ +class OIDC::IdToken::TableComponentPreview < Lookbook::Preview + def default(id_tokens: OIDC::IdToken.limit(3)) + render OIDC::IdToken::TableComponent.new( + id_tokens: + ) + end +end diff --git a/test/components/previews/oidc/trusted_publisher/github_action/form_component_preview.rb b/test/components/previews/oidc/trusted_publisher/github_action/form_component_preview.rb new file mode 100644 index 00000000000..c02271a38fe --- /dev/null +++ b/test/components/previews/oidc/trusted_publisher/github_action/form_component_preview.rb @@ -0,0 +1,22 @@ +class OIDC::TrustedPublisher::GitHubAction::FormComponentPreview < Lookbook::Preview + # @param factory select "factory for the containing trusted publisher" { choices: [oidc_rubygem_trusted_publisher, oidc_pending_trusted_publisher] } + def default(factory: :oidc_rubygem_trusted_publisher, environment: nil, repository_name: "rubygem2", workflow_filename: "push_gem.yml") + github_action = FactoryBot.build(:oidc_trusted_publisher_github_action, environment:, repository_name:, workflow_filename:) + trusted_publisher = FactoryBot.build(factory, trusted_publisher: github_action) + render Wrapper.new(form_object: trusted_publisher) + end + + class Wrapper < Phlex::HTML + include Phlex::Rails::Helpers::FormWith + + extend Dry::Initializer + option :form_object + + def view_template + form_with(model: form_object, url: "/") do |github_action_form| + render OIDC::TrustedPublisher::GitHubAction::FormComponent.new(github_action_form:) + github_action_form.submit class: "form__submit", disabled: true + end + end + end +end diff --git a/test/components/previews/oidc/trusted_publisher/github_action/table_component_preview.rb b/test/components/previews/oidc/trusted_publisher/github_action/table_component_preview.rb new file mode 100644 index 00000000000..24d699b7697 --- /dev/null +++ b/test/components/previews/oidc/trusted_publisher/github_action/table_component_preview.rb @@ -0,0 +1,7 @@ +class OIDC::TrustedPublisher::GitHubAction::TableComponentPreview < Lookbook::Preview + # @param environment text The environment for the GitHub Action + def default(environment: nil, repository_name: nil, workflow_filename: nil) + github_action = FactoryBot.build(:oidc_trusted_publisher_github_action, **{ environment:, repository_name:, workflow_filename: }.compact) + render OIDC::TrustedPublisher::GitHubAction::TableComponent.new(github_action:) + end +end diff --git a/test/factories.rb b/test/factories.rb deleted file mode 100644 index abd4aa47f4c..00000000000 --- a/test/factories.rb +++ /dev/null @@ -1,161 +0,0 @@ -FactoryBot.define do - sequence :email do |n| - "user#{n}@example.com" - end - - sequence :handle do |n| - "handle#{n}" - end - - factory :user do - email - handle - password { PasswordHelpers::SECURE_TEST_PASSWORD } - api_key { "secret123" } - email_confirmed { true } - end - - factory :dependency do - gem_dependency { Gem::Dependency.new(Rubygem.last.name, "1.0.0") } - rubygem - version - - trait :runtime do - end - - trait :development do - gem_dependency { Gem::Dependency.new(Rubygem.last.name, "1.0.0", :development) } - end - - trait :unresolved do - gem_dependency { Gem::Dependency.new("unresolved-gem-nothere", "1.0.0") } - rubygem { nil } - end - end - - factory :linkset do - rubygem - home { "http://example.com" } - wiki { "http://example.com" } - docs { "http://example.com" } - mail { "http://example.com" } - code { "http://example.com" } - bugs { "http://example.com" } - end - - factory :ownership do - rubygem - user - confirmed_at { Time.current } - authorizer { user } - trait :unconfirmed do - confirmed_at { nil } - end - end - - factory :subscription do - rubygem - user - end - - factory :api_key do - transient { key { "12345" } } - - user - name { "ci-key" } - - # enabled by default. disabled when show_dashboard is enabled. - index_rubygems { show_dashboard ? false : true } - - hashed_key { Digest::SHA256.hexdigest(key) } - end - - sequence :name do |n| - "RubyGem#{n}" - end - - factory :rubygem do - transient do - owners { [] } - number { nil } - downloads { 0 } - end - - name - - after(:build) do |rubygem, evaluator| - if evaluator.linkset - rubygem.linkset = evaluator.linkset - else - build(:linkset, rubygem: rubygem) - end - end - - after(:create) do |rubygem, evaluator| - evaluator.owners.each do |owner| - create(:ownership, rubygem: rubygem, user: owner) - end - - create(:version, rubygem: rubygem, number: evaluator.number) if evaluator.number - GemDownload.increment(evaluator.downloads, rubygem_id: rubygem.id, version_id: 0) if evaluator.downloads - end - end - - sequence :number do |n| - "0.0.#{n}" - end - - factory :version do - authors { ["Joe User"] } - built_at { 1.day.ago } - description { "Some awesome gem" } - indexed { true } - metadata { { "foo" => "bar" } } - number - canonical_number { Gem::Version.new(number).canonical_segments.join(".") } - platform { "ruby" } - required_rubygems_version { ">= 2.6.3" } - required_ruby_version { ">= 2.0.0" } - licenses { "MIT" } - requirements { "Opencv" } - rubygem - size { 1024 } - # In reality sha256 is different for different version - # sha256 is calculated in Pusher, we don't use pusher to create versions in tests - sha256 { "tdQEXD9Gb6kf4sxqvnkjKhpXzfEE96JucW4KHieJ33g=" } - - trait :yanked do - indexed { false } - end - end - - sequence :url do |n| - "http://example#{n}.com" - end - - factory :web_hook do - rubygem - url - user - - factory :global_web_hook do - rubygem { nil } - end - end - - factory :gem_download do - rubygem_id { 0 } - version_id { 0 } - count { 0 } - end - - factory :sendgrid_event do - sequence(:sendgrid_id) { |n| "TestSendgridId#{n}" } - status { "pending" } - payload { {} } - end - - factory :gem_typo_exception do - name - end -end diff --git a/test/factories/admin/github_user.rb b/test/factories/admin/github_user.rb new file mode 100644 index 00000000000..2d87c6456be --- /dev/null +++ b/test/factories/admin/github_user.rb @@ -0,0 +1,34 @@ +FactoryBot.define do + factory :admin_github_user, class: "Admin::GitHubUser" do + login { "jackson-keeling" } + avatar_url { "MyString" } + sequence(:github_id, &:to_s) + + oauth_token { SecureRandom.hex(10) } + is_admin { false } + info_data { { viewer: { login: login, id: github_id } } } + + trait :is_admin do + is_admin { true } + info_data do + { + viewer: { + login: login, + id: github_id, + organization: { + name: "RubyGems", + login: "rubygems", + viewerIsAMember: true, + teams: { + edges: [ + { node: { slug: "rubygems-org" } }, + { node: { slug: "security" } } + ] + } + } + } + } + end + end + end +end diff --git a/test/factories/api_key.rb b/test/factories/api_key.rb new file mode 100644 index 00000000000..549209452db --- /dev/null +++ b/test/factories/api_key.rb @@ -0,0 +1,23 @@ +FactoryBot.define do + factory :api_key do + transient { key { "12345" } } + transient { rubygem { nil } } + + owner factory: %i[user] + name { "ci-key" } + + # enabled by default. disabled when show_dashboard is enabled. + scopes { %w[index_rubygems] } + + hashed_key { Digest::SHA256.hexdigest(key) } + + after(:build) do |api_key, evaluator| + api_key.rubygem_id = evaluator.rubygem.id if evaluator.rubygem + end + + trait :trusted_publisher do + owner factory: %i[oidc_trusted_publisher_github_action] + transient { key { SecureRandom.hex(4) } } + end + end +end diff --git a/test/factories/api_key_rubygem_scope.rb b/test/factories/api_key_rubygem_scope.rb new file mode 100644 index 00000000000..7106bb3cfb3 --- /dev/null +++ b/test/factories/api_key_rubygem_scope.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :api_key_rubygem_scope do + ownership + api_key { association(:api_key, key: SecureRandom.hex(24)) } + end +end diff --git a/test/factories/audit.rb b/test/factories/audit.rb new file mode 100644 index 00000000000..a202fe578f4 --- /dev/null +++ b/test/factories/audit.rb @@ -0,0 +1,31 @@ +FactoryBot.define do + factory :audit do + admin_github_user + + comment { "A nice long comment" } + action { "Admin Action" } + auditable { association(:web_hook) } + + transient do + records do + {} + end + + fields do + { "field1" => "field1value", "field2" => %w[a b c] } + end + + arguments do + { "argument1" => true } + end + + models do + [] + end + end + + after :create do |audit, options| + audit.update(audited_changes: { records: options.records, fields: options.fields, arguments: options.arguments, models: options.models }) + end + end +end diff --git a/test/factories/deletion.rb b/test/factories/deletion.rb new file mode 100644 index 00000000000..6ade22c3397 --- /dev/null +++ b/test/factories/deletion.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :deletion do + user + version + end +end diff --git a/test/factories/dependency.rb b/test/factories/dependency.rb new file mode 100644 index 00000000000..99c42167db3 --- /dev/null +++ b/test/factories/dependency.rb @@ -0,0 +1,25 @@ +FactoryBot.define do + factory :dependency do + gem_dependency do + rubygem = Rubygem.last || create(:rubygem) + Gem::Dependency.new(rubygem.name, "1.0.0") + end + + rubygem + version + + trait :runtime + + trait :development do + gem_dependency do + rubygem = Rubygem.last || create(:rubygem) + Gem::Dependency.new(rubygem.name, "1.0.0", :development) + end + end + + trait :unresolved do + gem_dependency { Gem::Dependency.new("unresolved-gem-nothere", "1.0.0") } + rubygem { nil } + end + end +end diff --git a/test/factories/events/organization_events.rb b/test/factories/events/organization_events.rb new file mode 100644 index 00000000000..09893b5a748 --- /dev/null +++ b/test/factories/events/organization_events.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :events_organization_event, class: "Events::OrganizationEvent" do + tag { "organization:created" } + organization + ip_address + additional { nil } + end +end diff --git a/test/factories/events/rubygem_events.rb b/test/factories/events/rubygem_events.rb new file mode 100644 index 00000000000..598137697c5 --- /dev/null +++ b/test/factories/events/rubygem_events.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :events_rubygem_event, class: "Events::RubygemEvent" do + tag { Events::RubygemEvent::OWNER_ADDED } + rubygem + ip_address + additional { nil } + end +end diff --git a/test/factories/events/user_agent_infos.rb b/test/factories/events/user_agent_infos.rb new file mode 100644 index 00000000000..7f2d14fc706 --- /dev/null +++ b/test/factories/events/user_agent_infos.rb @@ -0,0 +1,12 @@ +FactoryBot.define do + factory :events_user_agent_info, class: "Events::UserAgentInfo" do + skip_create + + installer { "installer" } + device { "device" } + os { "os" } + user_agent { "user_agent" } + implementation { "implementation" } + system { "system" } + end +end diff --git a/test/factories/events/user_events.rb b/test/factories/events/user_events.rb new file mode 100644 index 00000000000..d37b6cb8ae0 --- /dev/null +++ b/test/factories/events/user_events.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :events_user_event, class: "Events::UserEvent" do + tag { "user:login:success" } + user + ip_address + additional { nil } + end +end diff --git a/test/factories/gem_download.rb b/test/factories/gem_download.rb new file mode 100644 index 00000000000..a1104bb70d7 --- /dev/null +++ b/test/factories/gem_download.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :gem_download do + rubygem_id { 0 } + version_id { 0 } + count { 0 } + end +end diff --git a/test/factories/gem_name_reservations.rb b/test/factories/gem_name_reservations.rb new file mode 100644 index 00000000000..9365e04768e --- /dev/null +++ b/test/factories/gem_name_reservations.rb @@ -0,0 +1,5 @@ +FactoryBot.define do + factory :gem_name_reservation do + name { "rail-ties" } + end +end diff --git a/test/factories/gem_typo_exception.rb b/test/factories/gem_typo_exception.rb new file mode 100644 index 00000000000..9d3e55d6bfd --- /dev/null +++ b/test/factories/gem_typo_exception.rb @@ -0,0 +1,5 @@ +FactoryBot.define do + factory :gem_typo_exception do + name + end +end diff --git a/test/factories/geoip_infos.rb b/test/factories/geoip_infos.rb new file mode 100644 index 00000000000..cf1e5e3aff8 --- /dev/null +++ b/test/factories/geoip_infos.rb @@ -0,0 +1,14 @@ +FactoryBot.define do + factory :geoip_info do + continent_code { "NA" } + country_code { "US" } + country_code3 { "USA" } + sequence(:country_name) { |n| "Country #{n}" } + region { "NY" } + city { "Buffalo" } + + trait :usa do + country_name { "United States of America" } + end + end +end diff --git a/test/factories/ip_addresses.rb b/test/factories/ip_addresses.rb new file mode 100644 index 00000000000..86b02218558 --- /dev/null +++ b/test/factories/ip_addresses.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :ip_address do + sequence(:ip_address) { |n| IPAddr.new(n, Socket::AF_INET6).to_s } + geoip_info + end +end diff --git a/test/factories/link_verifications.rb b/test/factories/link_verifications.rb new file mode 100644 index 00000000000..9dc833e1619 --- /dev/null +++ b/test/factories/link_verifications.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + factory :link_verification do + linkable factory: :rubygem + sequence(:uri) { |n| "https://example.com/#{n}" } + last_verified_at { nil } + last_failure_at { nil } + failures_since_last_verification { 0 } + end +end diff --git a/test/factories/linkset.rb b/test/factories/linkset.rb new file mode 100644 index 00000000000..365d257433c --- /dev/null +++ b/test/factories/linkset.rb @@ -0,0 +1,11 @@ +FactoryBot.define do + factory :linkset do + rubygem + home { "http://example.com" } + wiki { "http://example.com" } + docs { "http://example.com" } + mail { "http://example.com" } + code { "http://example.com" } + bugs { "http://example.com" } + end +end diff --git a/test/factories/log_ticket.rb b/test/factories/log_ticket.rb new file mode 100644 index 00000000000..2257eba08b1 --- /dev/null +++ b/test/factories/log_ticket.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :log_ticket do + sequence(:key) { "key-#{_1}" } + sequence(:directory) { "directory-#{_1}" } + status { :pending } + end +end diff --git a/test/factories/maintenance_tasks/run.rb b/test/factories/maintenance_tasks/run.rb new file mode 100644 index 00000000000..d20bbf22c31 --- /dev/null +++ b/test/factories/maintenance_tasks/run.rb @@ -0,0 +1,5 @@ +FactoryBot.define do + factory :maintenance_tasks_run, class: "MaintenanceTasks::Run" do + task_name { Maintenance::UserTotpSeedEmptyToNilTask.name } + end +end diff --git a/test/factories/memberships.rb b/test/factories/memberships.rb new file mode 100644 index 00000000000..5920d25336f --- /dev/null +++ b/test/factories/memberships.rb @@ -0,0 +1,16 @@ +FactoryBot.define do + factory :membership do + user + organization + confirmed_at { Time.zone.now } + role { :maintainer } + + trait :owner do + role { :owner } + end + + trait :admin do + role { :admin } + end + end +end diff --git a/test/factories/oidc/api_key_role.rb b/test/factories/oidc/api_key_role.rb new file mode 100644 index 00000000000..97d9615ddfa --- /dev/null +++ b/test/factories/oidc/api_key_role.rb @@ -0,0 +1,23 @@ +FactoryBot.define do + factory :oidc_api_key_role, class: "OIDC::ApiKeyRole" do + provider factory: :oidc_provider + user + api_key_permissions do + { + scopes: ["push_rubygem"] + } + end + sequence(:name) { |n| "GitHub Pusher #{n}" } + access_policy do + { + statements: [ + { effect: "allow", + principal: { oidc: provider.issuer }, + conditions: [ + { operator: "string_equals", claim: "sub", value: "repo:segiddins/oidc-test:ref:refs/heads/main" } + ] } + ] + } + end + end +end diff --git a/test/factories/oidc/id_token.rb b/test/factories/oidc/id_token.rb new file mode 100644 index 00000000000..67277532d22 --- /dev/null +++ b/test/factories/oidc/id_token.rb @@ -0,0 +1,24 @@ +FactoryBot.define do + factory :oidc_id_token, class: "OIDC::IdToken" do + api_key_role factory: :oidc_api_key_role + api_key { association :api_key, key: SecureRandom.hex(20), **api_key_role.api_key_permissions.create_params(api_key_role.user) } + jwt do + { + claims: { + claim1: "value1", + claim2: "value2", + jti: + }, + header: { + alg: "RS256", + kid: "test", + typ: "JWT" + } + } + end + + transient do + sequence(:jti) { |_n| SecureRandom.uuid } + end + end +end diff --git a/test/factories/oidc/pending_trusted_publishers.rb b/test/factories/oidc/pending_trusted_publishers.rb new file mode 100644 index 00000000000..02e290f254f --- /dev/null +++ b/test/factories/oidc/pending_trusted_publishers.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :oidc_pending_trusted_publisher, class: "OIDC::PendingTrustedPublisher" do + sequence(:rubygem_name) { |n| "pending-rubygem#{n}" } + user + trusted_publisher factory: %i[oidc_trusted_publisher_github_action] + expires_at { 7.days.from_now } + end +end diff --git a/test/factories/oidc/provider.rb b/test/factories/oidc/provider.rb new file mode 100644 index 00000000000..f745ff2cc63 --- /dev/null +++ b/test/factories/oidc/provider.rb @@ -0,0 +1,67 @@ +FactoryBot.define do + factory :oidc_provider, class: "OIDC::Provider" do + sequence(:issuer) { |n| "https://#{n}.token.actions.githubusercontent.com" } + configuration do + { + issuer: issuer, + jwks_uri: "#{issuer}/.well-known/jwks", + subject_types_supported: %w[ + public + pairwise + ], + response_types_supported: [ + "id_token" + ], + claims_supported: %w[ + sub + aud + exp + iat + iss + jti + nbf + ref + repository + repository_id + repository_owner + repository_owner_id + run_id + run_number + run_attempt + actor + actor_id + workflow + workflow_ref + workflow_sha + head_ref + base_ref + event_name + ref_type + environment + environment_node_id + job_workflow_ref + job_workflow_sha + repository_visibility + runner_environment + ], + id_token_signing_alg_values_supported: [ + "RS256" + ], + scopes_supported: [ + "openid" + ] + } + end + jwks do + { + keys: [ + pkey&.to_jwk + ].compact + } + end + + transient do + pkey { OpenSSL::PKey::RSA.generate(2048) } + end + end +end diff --git a/test/factories/oidc/rubygem_trusted_publishers.rb b/test/factories/oidc/rubygem_trusted_publishers.rb new file mode 100644 index 00000000000..efe4af433da --- /dev/null +++ b/test/factories/oidc/rubygem_trusted_publishers.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :oidc_rubygem_trusted_publisher, class: "OIDC::RubygemTrustedPublisher" do + rubygem + trusted_publisher factory: %i[oidc_trusted_publisher_github_action] + end +end diff --git a/test/factories/oidc/trusted_publisher/github_actions.rb b/test/factories/oidc/trusted_publisher/github_actions.rb new file mode 100644 index 00000000000..4fa51fdd885 --- /dev/null +++ b/test/factories/oidc/trusted_publisher/github_actions.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + factory :oidc_trusted_publisher_github_action, class: "OIDC::TrustedPublisher::GitHubAction" do + repository_owner { "example" } + sequence(:repository_name) { |n| "rubygem#{n}" } + repository_owner_id { "123456" } + workflow_filename { "push_gem.yml" } + environment { nil } + end +end diff --git a/test/factories/organizations.rb b/test/factories/organizations.rb new file mode 100644 index 00000000000..77155e05655 --- /dev/null +++ b/test/factories/organizations.rb @@ -0,0 +1,31 @@ +FactoryBot.define do + factory :organization do + transient do + owners { [] } + admins { [] } + maintainers { [] } + end + + handle + name + deleted_at { nil } + + after(:create) do |organization, evaluator| + evaluator.owners.each do |user| + create(:membership, user: user, organization: organization, role: :owner) + end + + evaluator.admins.each do |user| + create(:membership, user: user, organization: organization, role: :admin) + end + + evaluator.maintainers.each do |user| + create(:membership, user: user, organization: organization, role: :maintainer) + end + end + + trait :with_members do + memberships { build_list(:membership, 2) } + end + end +end diff --git a/test/factories/ownership.rb b/test/factories/ownership.rb new file mode 100644 index 00000000000..9931e2754e2 --- /dev/null +++ b/test/factories/ownership.rb @@ -0,0 +1,17 @@ +FactoryBot.define do + factory :ownership do + rubygem + user + confirmed_at { Time.current } + authorizer { association :user } + role { :owner } + + trait :unconfirmed do + confirmed_at { nil } + end + + trait :maintainer do + role { :maintainer } + end + end +end diff --git a/test/factories/ownership_call.rb b/test/factories/ownership_call.rb new file mode 100644 index 00000000000..94326189ac3 --- /dev/null +++ b/test/factories/ownership_call.rb @@ -0,0 +1,10 @@ +FactoryBot.define do + factory :ownership_call do + rubygem + user + note { "small note" } + trait :closed do + status { "closed" } + end + end +end diff --git a/test/factories/ownership_request.rb b/test/factories/ownership_request.rb new file mode 100644 index 00000000000..ccf5de19f00 --- /dev/null +++ b/test/factories/ownership_request.rb @@ -0,0 +1,19 @@ +FactoryBot.define do + factory :ownership_request do + rubygem + user + note { "small note here" } + status { "opened" } + approver { nil } + trait :approved do + approver { user } + status { "approved" } + end + trait :closed do + status { "closed" } + end + trait :with_ownership_call do + ownership_call + end + end +end diff --git a/test/factories/rubygem.rb b/test/factories/rubygem.rb new file mode 100644 index 00000000000..285e833a735 --- /dev/null +++ b/test/factories/rubygem.rb @@ -0,0 +1,33 @@ +FactoryBot.define do + factory :rubygem do + transient do + owners { [] } + maintainers { [] } + number { nil } + downloads { 0 } + end + + name + + after(:build) do |rubygem, evaluator| + if evaluator.linkset + rubygem.linkset = evaluator.linkset + else + build(:linkset, rubygem: rubygem) + end + end + + after(:create) do |rubygem, evaluator| + evaluator.owners.each do |owner| + create(:ownership, rubygem: rubygem, user: owner, role: :owner) + end + + evaluator.maintainers.each do |maintainer| + create(:ownership, rubygem: rubygem, user: maintainer, role: :maintainer) + end + + create(:version, rubygem: rubygem, number: evaluator.number) if evaluator.number + GemDownload.increment(evaluator.downloads, rubygem_id: rubygem.id, version_id: 0) if evaluator.downloads + end + end +end diff --git a/test/factories/sendgrid_event.rb b/test/factories/sendgrid_event.rb new file mode 100644 index 00000000000..8e7d43fffdc --- /dev/null +++ b/test/factories/sendgrid_event.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :sendgrid_event do + sequence(:sendgrid_id) { |n| "TestSendgridId#{n}" } + status { "pending" } + payload { {} } + end +end diff --git a/test/factories/sequences.rb b/test/factories/sequences.rb new file mode 100644 index 00000000000..28e64ec1699 --- /dev/null +++ b/test/factories/sequences.rb @@ -0,0 +1,21 @@ +FactoryBot.define do + sequence :email do |n| + "user#{n}@example.com" + end + + sequence :handle do |n| + "handle#{n}" + end + + sequence :name do |n| + "RubyGem#{n}" + end + + sequence :number do |n| + "0.0.#{n}" + end + + sequence :url do |n| + "http://example#{n}.com" + end +end diff --git a/test/factories/subscription.rb b/test/factories/subscription.rb new file mode 100644 index 00000000000..ee209ff40a1 --- /dev/null +++ b/test/factories/subscription.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :subscription do + rubygem + user + end +end diff --git a/test/factories/user.rb b/test/factories/user.rb new file mode 100644 index 00000000000..29bd04cb235 --- /dev/null +++ b/test/factories/user.rb @@ -0,0 +1,56 @@ +require_relative "../helpers/password_helpers" + +FactoryBot.define do + factory :user do + email + handle + password { PasswordHelpers::SECURE_TEST_PASSWORD } + api_key { "secret123" } + email_confirmed { true } + + transient do + mfa_recovery_codes { [] } + end + mfa_hashed_recovery_codes { mfa_recovery_codes.map { |code| BCrypt::Password.create(code) } } + + trait :unconfirmed do + email_confirmed { false } + unconfirmed_email { "#{SecureRandom.hex(8)}#{email}" } + end + + trait :mfa_enabled do + totp_seed { "123abc" } + mfa_level { User.mfa_levels["ui_and_api"] } + mfa_recovery_codes { %w[aaa bbb ccc] } + end + + trait :disabled do + totp_seed { "" } + mfa_level { User.mfa_levels["disabled"] } + mfa_recovery_codes { [] } + end + + trait :ui_only do + totp_seed { "123abc" } + mfa_level { User.mfa_levels["ui_only"] } + mfa_recovery_codes { %w[aaa bbb ccc] } + end + + trait :ui_and_api do + totp_seed { "123abc" } + mfa_level { User.mfa_levels["ui_and_api"] } + mfa_recovery_codes { %w[aaa bbb ccc] } + end + + trait :ui_and_gem_signin do + totp_seed { "123abc" } + mfa_level { User.mfa_levels["ui_and_gem_signin"] } + mfa_recovery_codes { %w[aaa bbb ccc] } + end + + trait :blocked do + email { "security+locked-#{SecureRandom.hex(4)}@rubygems.org" } + blocked_email { "test@example.com" } + end + end +end diff --git a/test/factories/version.rb b/test/factories/version.rb new file mode 100644 index 00000000000..520cdb82c8e --- /dev/null +++ b/test/factories/version.rb @@ -0,0 +1,38 @@ +FactoryBot.define do + factory :version do + authors { ["Joe User"] } + built_at { 1.day.ago } + description { "Some awesome gem" } + indexed { true } + metadata { { "foo" => "bar" } } + number + canonical_number { Gem::Version.new(number).canonical_segments.join(".") } + platform { "ruby" } + gem_platform { Gem::Platform.new(platform).to_s } + required_rubygems_version { ">= 2.6.3" } + required_ruby_version { ">= 2.0.0" } + licenses { "MIT" } + requirements { "Opencv" } + rubygem + size { 1024 } + # In reality sha256 is different for different version + # sha256 is calculated in Pusher, we don't use pusher to create versions in tests + sha256 { "tdQEXD9Gb6kf4sxqvnkjKhpXzfEE96JucW4KHieJ33g=" } + spec_sha256 { Digest::SHA2.base64digest("#{rubygem.name}-#{number}-#{platform}") } + + trait :yanked do + indexed { false } + end + + trait :mfa_required do + metadata { { "rubygems_mfa_required" => "true" } } + end + + after(:create) do |version| + if version.info_checksum.blank? + checksum = GemInfo.new(version.rubygem.name).info_checksum + version.update_attribute :info_checksum, checksum + end + end + end +end diff --git a/test/factories/web_hook.rb b/test/factories/web_hook.rb new file mode 100644 index 00000000000..7755772b540 --- /dev/null +++ b/test/factories/web_hook.rb @@ -0,0 +1,11 @@ +FactoryBot.define do + factory :web_hook do + rubygem + url + user + + factory :global_web_hook do + rubygem { nil } + end + end +end diff --git a/test/factories/webauthn_credential.rb b/test/factories/webauthn_credential.rb new file mode 100644 index 00000000000..4a7bf50e156 --- /dev/null +++ b/test/factories/webauthn_credential.rb @@ -0,0 +1,14 @@ +FactoryBot.define do + factory :webauthn_credential do + user + sequence(:external_id) { |n| "webauthn-credential-#{n}" } + public_key { "abc" } + nickname { "Key #{SecureRandom.hex(24)}" } + + trait :primary + + trait :backup do + nickname { "Backup key" } + end + end +end diff --git a/test/factories/webauthn_verification.rb b/test/factories/webauthn_verification.rb new file mode 100644 index 00000000000..97419e4d392 --- /dev/null +++ b/test/factories/webauthn_verification.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + factory :webauthn_verification do + user + path_token { SecureRandom.base58(16) } + path_token_expires_at { Time.now.utc + 2.minutes } + otp { SecureRandom.base58(16) } + otp_expires_at { Time.now.utc + 2.minutes } + end +end diff --git a/test/functional/adoptions_controller_test.rb b/test/functional/adoptions_controller_test.rb new file mode 100644 index 00000000000..33fb98b98da --- /dev/null +++ b/test/functional/adoptions_controller_test.rb @@ -0,0 +1,148 @@ +require "test_helper" + +class AdoptionsControllerTest < ActionController::TestCase + context "on GET to index" do + setup do + @user = create(:user) + end + context "signed user is owner of rubygem" do + setup do + @rubygem = create(:rubygem, owners: [@user], downloads: 2_000) + create(:version, rubygem: @rubygem, created_at: 2.years.ago) + sign_in_as @user + session[:verification] = 10.minutes.from_now + session[:verified_user] = @user.id + end + + teardown do + session[:verification] = nil + session[:verified_user] = nil + end + + context "ownership call exists" do + setup do + @ownership_call = create(:ownership_call, rubygem: @rubygem, user: @user, note: "example call") + end + + context "ownership request exists" do + setup do + @ownership_request = create(:ownership_request, rubygem: @rubygem, ownership_call: @ownership_call, note: "example request") + get :index, params: { rubygem_id: @rubygem.name } + end + should respond_with :success + + should "have button for approve and close all ownership requests" do + assert page.has_content?("example request") + assert page.has_selector?("button[type='submit']", text: "Close") + assert page.has_selector?("button[type='submit']", text: "Close all") + end + end + + context "ownership request doesn't exist" do + setup do + get :index, params: { rubygem_id: @rubygem.name } + end + should respond_with :success + + should "have button to close ownership call" do + assert page.has_content?("example call") + assert page.has_selector?("button[type='submit']", text: "Close") + end + end + end + + context "ownership call doesn't exist" do + context "ownership request exists" do + setup do + @ownership_request = create(:ownership_request, rubygem: @rubygem) + get :index, params: { rubygem_id: @rubygem.name } + end + should respond_with :success + + should "have button to create ownership call" do + assert page.has_selector?("input[value='Create ownership call']") + end + end + + context "ownership request doesn't exist" do + setup do + get :index, params: { rubygem_id: @rubygem.name } + end + should respond_with :success + + should "not show any ownership request" do + assert page.has_content?("No ownership requests for #{@rubygem.name}") + end + end + end + end + + context "signed in user is not owner of rubygem" do + setup do + @rubygem = create(:rubygem, downloads: 2_000) + create(:version, rubygem: @rubygem, created_at: 2.years.ago) + sign_in_as @user + end + context "ownership call exists" do + setup do + @ownership_call = create(:ownership_call, rubygem: @rubygem) + end + + context "ownership request by user exists" do + setup do + @ownership_request = create(:ownership_request, rubygem: @rubygem, ownership_call: @ownership_call, user: @user, note: "example request") + get :index, params: { rubygem_id: @rubygem.name } + end + should respond_with :success + should "have button to close ownership request" do + assert page.has_content?("example request") + assert page.has_selector?("button[type='submit']", text: "Close") + end + end + + context "ownership request doesn't exist" do + setup do + get :index, params: { rubygem_id: @rubygem.name } + end + should respond_with :success + + should "have button to create ownership request" do + assert page.has_selector?("input[value='Create ownership request']") + end + end + end + + context "ownership call doesn't exist" do + setup do + get :index, params: { rubygem_id: @rubygem.name } + end + should respond_with :success + + should "not show any ownership request" do + assert page.has_content?("There are no ownership calls for #{@rubygem.name}") + end + end + end + + context "user is not signed in" do + context "ownership call and request exits" do + setup do + @rubygem = create(:rubygem, downloads: 2_000) + create(:version, rubygem: @rubygem, created_at: 2.years.ago) + @ownership_call = create(:ownership_call, rubygem: @rubygem, note: "example call") + @ownership_request = create(:ownership_request, rubygem: @rubygem, ownership_call: @ownership_call, user: @user, note: "example request") + get :index, params: { rubygem_id: @rubygem.name } + end + + should respond_with :success + should "not show ownership request or create button" do + refute page.has_content?("example request") + refute page.has_selector?("input[value='Create']") + end + should "show ownership call" do + assert page.has_content?("example call") + end + end + end + end +end diff --git a/test/functional/api/deprecated_controller_test.rb b/test/functional/api/deprecated_controller_test.rb index 55bf8af43e0..5a2f0edef57 100644 --- a/test/functional/api/deprecated_controller_test.rb +++ b/test/functional/api/deprecated_controller_test.rb @@ -3,12 +3,15 @@ class Api::DeprecatedControllerTest < ActionController::TestCase should "route old paths to new controller" do route = { controller: "api/deprecated" } + assert_recognizes(route.merge(action: "index"), path: "/api_key") route = { controller: "api/deprecated", id: "rails" } + assert_recognizes(route.merge(action: "index"), path: "/gems/rails.json") route = { controller: "api/deprecated" } + assert_recognizes(route.merge(action: "index"), path: "/gems", method: :post) assert_recognizes(route.merge(action: "index"), path: "api/v1/gems/unyank", method: :put) diff --git a/test/functional/api/v1/activities_controller_test.rb b/test/functional/api/v1/activities_controller_test.rb index aa2472d4886..0fb5d7906eb 100644 --- a/test/functional/api/v1/activities_controller_test.rb +++ b/test/functional/api/v1/activities_controller_test.rb @@ -12,7 +12,8 @@ class Api::V1::ActivitiesControllerTest < ActionController::TestCase create(:version, rubygem: @sinatra) @foobar = create(:rubygem, name: "foobar") - create(:version, rubygem: @foobar) + create(:version, rubygem: @foobar, + dependencies: [build(:dependency, rubygem: @rails), build(:dependency, rubygem: @sinatra)]) end should "return correct JSON for latest gems" do diff --git a/test/functional/api/v1/api_keys_controller_test.rb b/test/functional/api/v1/api_keys_controller_test.rb index ef67cd6ba6d..b0ba00d58e5 100644 --- a/test/functional/api/v1/api_keys_controller_test.rb +++ b/test/functional/api/v1/api_keys_controller_test.rb @@ -1,8 +1,11 @@ require "test_helper" class Api::V1::ApiKeysControllerTest < ActionController::TestCase + include ActiveJob::TestHelper + should "route new paths to new controller" do route = { controller: "api/v1/api_keys", action: "show" } + assert_recognizes(route, "/api/v1/api_key") end @@ -20,10 +23,12 @@ def self.should_respond_to(format, to_meth = :to_s) should respond_with :success should "return API key" do response = yield(@response.body) + assert_not_nil response assert_kind_of Hash, response hashed_key = @user.api_keys.first.hashed_key + assert_equal hashed_key, Digest::SHA256.hexdigest(response["rubygems_api_key".send(to_meth)]) end end @@ -31,37 +36,43 @@ def self.should_respond_to(format, to_meth = :to_s) def self.should_deny_access should "deny access" do - assert_response 401 + assert_response :unauthorized assert_match "HTTP Basic: Access denied.", @response.body end end def self.should_deny_access_incorrect_otp should "deny access" do - assert_response 401 + assert_response :unauthorized assert_match I18n.t("otp_incorrect"), @response.body end end def self.should_deny_access_missing_otp should "deny access" do - assert_response 401 + assert_response :unauthorized assert_match I18n.t("otp_missing"), @response.body end + + should "return body that starts with MFA enabled message" do + assert @response.body.start_with?("You have enabled multifactor authentication") + end end def self.should_return_api_key_successfully should respond_with :success should "return API key" do hashed_key = @user.api_keys.first.hashed_key + assert_equal hashed_key, Digest::SHA256.hexdigest(@response.body) end end def self.should_deliver_api_key_created_email should "deliver api key created email" do - refute ActionMailer::Base.deliveries.empty? + refute_empty ActionMailer::Base.deliveries email = ActionMailer::Base.deliveries.last + assert_equal [@user.email], email.to assert_equal ["no-reply@mailer.rubygems.org"], email.from assert_equal "New API key created for rubygems.org", email.subject @@ -71,7 +82,7 @@ def self.should_deliver_api_key_created_email def self.should_not_signin_user should "not sign in user" do - refute @controller.request.env[:clearance].signed_in? + refute_predicate @controller.request.env[:clearance], :signed_in? end end @@ -92,9 +103,10 @@ def self.should_expect_otp_for_show context "with correct OTP" do setup do - @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.mfa_seed).now - get :show - Delayed::Worker.new.work_off + @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.totp_seed).now + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + get :show + end end should_return_api_key_successfully @@ -120,7 +132,7 @@ def self.should_expect_otp_for_create context "with correct OTP" do setup do - @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.mfa_seed).now + @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.totp_seed).now post :create, params: { name: "test", index_rubygems: "true" } end @@ -145,8 +157,8 @@ def self.should_expect_otp_for_update context "with correct OTP" do setup do - @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.mfa_seed).now - @api_key = create(:api_key, user: @user, key: "12345", push_rubygem: true) + @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.totp_seed).now + @api_key = create(:api_key, owner: @user, key: "12345", scopes: %i[push_rubygem]) put :update, params: { api_key: "12345", index_rubygems: "true" } @api_key.reload @@ -154,8 +166,8 @@ def self.should_expect_otp_for_update should respond_with :success should "keep current scope enabled and update scope in params" do - assert @api_key.can_index_rubygems? - assert @api_key.can_push_rubygem? + assert_predicate @api_key, :can_index_rubygems? + assert_predicate @api_key, :can_push_rubygem? end end end @@ -167,7 +179,7 @@ def self.should_expect_otp_for_update get :show end should "deny access" do - assert_response 401 + assert_response :unauthorized assert_match "HTTP Basic: Access denied.", @response.body end end @@ -178,7 +190,7 @@ def self.should_expect_otp_for_update end should_respond_to(:yaml, :to_sym) do |body| - YAML.safe_load(body, [Symbol]) + YAML.safe_load(body, permitted_classes: [Symbol]) end context "with no credentials" do @@ -195,12 +207,22 @@ def self.should_expect_otp_for_update should_deny_access end + context "with credentials with invalid encoding" do + setup do + @user = create(:user) + authorize_with("\x12\xff\x12:creds".force_encoding(Encoding::UTF_8)) + get :show + end + should_deny_access + end + context "with correct credentials" do setup do @user = create(:user) authorize_with("#{@user.email}:#{@user.password}") - get :show, format: "text" - Delayed::Worker.new.work_off + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + get :show, format: "text" + end end should_return_api_key_successfully @@ -211,7 +233,7 @@ def self.should_expect_otp_for_update context "when user has enabled MFA for UI and API" do setup do @user = create(:user) - @user.enable_mfa!(ROTP::Base32.random_base32, :ui_and_api) + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) authorize_with("#{@user.email}:#{@user.password}") end @@ -221,7 +243,7 @@ def self.should_expect_otp_for_update context "when user has enabled MFA for UI and gem signin" do setup do @user = create(:user) - @user.enable_mfa!(ROTP::Base32.random_base32, :ui_and_gem_signin) + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_gem_signin) authorize_with("#{@user.email}:#{@user.password}") end @@ -237,7 +259,7 @@ def self.should_expect_otp_for_update authorize_with("#{@user.handle}:pass") get :show - assert_response 401 + assert_response :unauthorized assert_match "HTTP Basic: Access denied.", @response.body end end @@ -262,25 +284,163 @@ def self.should_expect_otp_for_update context "with correct credentials" do setup do authorize_with("#{@user.email}:#{@user.password}") + end + + context "on successful save" do + setup do + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + post :create, params: { name: "test-key", index_rubygems: "true" }, format: "text" + end + end + + should_return_api_key_successfully + + should "deliver api key created email" do + refute_empty ActionMailer::Base.deliveries + email = ActionMailer::Base.deliveries.last + + assert_equal [@user.email], email.to + assert_equal ["no-reply@mailer.rubygems.org"], email.from + assert_equal "New API key created for rubygems.org", email.subject + assert_match "test-key", email.body.to_s + end + end + + context "on unsuccessful save" do + setup do + post :create, params: { name: "test-key", index_rubygems: "true", show_dashboard: "true" }, format: "text" + end + + should respond_with :unprocessable_content + + should "not create api key" do + assert_empty @user.reload.api_keys + end + + should "respond with error message" do + assert_equal "Show dashboard scope must be enabled exclusively", @response.body + end + end + + context "with MFA param set" do + setup do + post :create, params: { name: "mfa", index_rubygems: "true", mfa: "true" }, format: "text" + end + + should_return_api_key_successfully + + should "have MFA" do + created_key = @user.api_keys.find_by(name: "mfa") + + assert created_key.mfa + end + end + + context "with rubygem_name param set" do + context "with a valid rubygem" do + setup do + @ownership = create(:ownership, user: @user) + end + + context "with applicable scoped enabled" do + setup do + post :create, + params: { name: "gem-scoped-key", push_rubygem: "true", rubygem_name: @ownership.rubygem.name }, + format: "text" + end + + should_return_api_key_successfully + + should "have a rubygem associated" do + created_key = @user.api_keys.find_by(name: "gem-scoped-key") + + assert_equal @ownership.rubygem, created_key.rubygem + end + end + + context "with applicable scoped disabled" do + setup do + post :create, + params: { name: "gem-scoped-key", index_rubygems: "true", rubygem_name: @ownership.rubygem.name }, + format: "text" + end + + should respond_with :unprocessable_content + + should "respond with an error" do + assert_equal "Rubygem scope can only be set for push/yank rubygem, and add/remove owner scopes", response.body + end + end + end + + context "with an rubygem name that the user is not an owner of" do + setup do + post :create, + params: { name: "gem-scoped-key", index_rubygems: "true", rubygem_name: "invalid-gem-name" }, + format: "text" + end + + should respond_with :unprocessable_content + + should "respond with an error" do + assert_equal "Rubygem could not be found", response.body + end + end + end + end + + context "when a user provides an OTP code" do + setup do + @user = create(:user) + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_gem_signin) + authorize_with("#{@user.email}:#{@user.password}") + @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.totp_seed).now post :create, params: { name: "test-key", index_rubygems: "true" }, format: "text" - Delayed::Worker.new.work_off end should_return_api_key_successfully + end - should "deliver api key created email" do - refute ActionMailer::Base.deliveries.empty? - email = ActionMailer::Base.deliveries.last - assert_equal [@user.email], email.to - assert_equal ["no-reply@mailer.rubygems.org"], email.from - assert_equal "New API key created for rubygems.org", email.subject - assert_match "test-key", email.body.to_s + context "when a user has webauthn enabled and no totp code is provided" do + setup do + @user = create(:user) + @webauthn_credential = create(:webauthn_credential, user: @user) + authorize_with("#{@user.email}:#{@user.password}") + post :create, params: { name: "test-key", index_rubygems: "true" }, format: "text" end + + should_deny_access_missing_otp + end + + context "when a user has webauthn enabled and totp code is provided" do + setup do + @user = create(:user) + @webauthn_credential = create(:webauthn_credential, user: @user) + @verification = create(:webauthn_verification, user: @user) + authorize_with("#{@user.email}:#{@user.password}") + @request.env["HTTP_OTP"] = @verification.otp + post :create, params: { name: "test-key", index_rubygems: "true" }, format: "text" + end + + should_return_api_key_successfully + end + + context "when a user has webauthn enabled and totp code is provided but invalid" do + setup do + @user = create(:user) + @webauthn_credential = create(:webauthn_credential, user: @user) + @verification = create(:webauthn_verification, user: @user) + authorize_with("#{@user.email}:#{@user.password}") + @request.env["HTTP_OTP"] = "123456" + post :create, params: { name: "test-key", index_rubygems: "true" }, format: "text" + end + + should_deny_access_incorrect_otp end context "when user has enabled MFA for UI and API" do setup do - @user.enable_mfa!(ROTP::Base32.random_base32, :ui_and_api) + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) authorize_with("#{@user.email}:#{@user.password}") end @@ -289,12 +449,95 @@ def self.should_expect_otp_for_update context "when user has enabled MFA for UI and gem signin" do setup do - @user.enable_mfa!(ROTP::Base32.random_base32, :ui_and_gem_signin) + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_gem_signin) authorize_with("#{@user.email}:#{@user.password}") end should_expect_otp_for_create end + + context "when mfa is required" do + setup do + User.any_instance.stubs(:mfa_required?).returns true + authorize_with("#{@user.email}:#{@user.password}") + end + + context "by user with mfa disabled" do + setup do + post :create, params: { name: "test-key", index_rubygems: "true" }, format: "text" + end + + should "deny access" do + assert_response :forbidden + mfa_error = I18n.t("multifactor_auths.api.mfa_required_not_yet_enabled").chomp + + assert_match mfa_error, @response.body + end + end + + context "by user on `ui_only` level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + post :create, params: { name: "test-key", index_rubygems: "true" }, format: "text" + end + + should "deny access" do + assert_response :forbidden + mfa_error = I18n.t("multifactor_auths.api.mfa_required_weak_level_enabled").chomp + + assert_match mfa_error, @response.body + end + end + + context "by user on `ui_and_gem_signin` level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_gem_signin) + post :create, params: { name: "test-key", index_rubygems: "true" }, format: "text" + end + + should_expect_otp_for_create + + should "not show error message" do + refute_includes @response.body, "For protection of your account and your gems" + end + end + + context "by user on `ui_and_api` level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + post :create, params: { name: "test-key", index_rubygems: "true" }, format: "text" + end + + should_expect_otp_for_create + + should "not show error message" do + refute_includes @response.body, "For protection of your account and your gems" + end + end + end + + context "expiration" do + setup do + authorize_with("#{@user.email}:#{@user.password}") + end + + should "not allow setting expiration in the past" do + assert_no_difference -> { @user.api_keys.count } do + post :create, params: { name: "test-key", index_rubygems: "true", expires_at: 1.day.ago }, format: "text" + + assert_response :unprocessable_content + end + end + + should "allow setting expiration in the future" do + expires_at = 1.day.from_now + post :create, params: { name: "test-key", index_rubygems: "true", expires_at: }, format: "text" + + assert_response :success + + assert_equal expires_at.change(usec: 0), @user.api_keys.last.expires_at + end + end end context "on PUT to update" do @@ -315,22 +558,65 @@ def self.should_expect_otp_for_update context "with correct credentials" do setup do - @api_key = create(:api_key, user: @user, key: "12345", push_rubygem: true) + @api_key = create(:api_key, owner: @user, key: "12345", scopes: %i[push_rubygem]) authorize_with("#{@user.email}:#{@user.password}") - put :update, params: { api_key: "12345", index_rubygems: "true" } - @api_key.reload end - should respond_with :success - should "keep current scope enabled and update scope in params" do - assert @api_key.can_index_rubygems? - assert @api_key.can_push_rubygem? + context "on successful save" do + setup do + put :update, params: { api_key: "12345", index_rubygems: "true", mfa: "true" } + @api_key.reload + end + + should respond_with :success + should "keep current scope enabled and update scope in params" do + assert_predicate @api_key, :can_index_rubygems? + assert_predicate @api_key, :can_push_rubygem? + end + + should "update MFA" do + assert @api_key.mfa + end + end + + context "on unsucessful save" do + setup do + put :update, params: { api_key: "12345", push_rubygem: "true", show_dashboard: "true" } + @api_key.reload + end + + should respond_with :unprocessable_content + + should "not update api key" do + refute_predicate @api_key, :can_show_dashboard? + end + + should "respond with error message" do + error = "Failed to update scopes for the API key ci-key: [\"Show dashboard scope must be enabled exclusively\"]" + + assert_equal @response.body, error + end + end + + context "expiration" do + should "not allow updating expiration" do + @api_key.update!(expires_at: 1.month.from_now) + assert_no_changes -> { @api_key.expires_at } do + put :update, params: { api_key: "12345", expires_at: 1.day.from_now } + end + end + + should "not allow adding expiration" do + assert_no_changes -> { @api_key.expires_at } do + put :update, params: { api_key: "12345", expires_at: 1.day.from_now } + end + end end end context "when user has enabled MFA for UI and API" do setup do - @user.enable_mfa!(ROTP::Base32.random_base32, :ui_and_api) + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) authorize_with("#{@user.email}:#{@user.password}") end @@ -339,7 +625,7 @@ def self.should_expect_otp_for_update context "when user has enabled MFA for UI and gem signin" do setup do - @user.enable_mfa!(ROTP::Base32.random_base32, :ui_and_gem_signin) + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_gem_signin) authorize_with("#{@user.email}:#{@user.password}") end diff --git a/test/functional/api/v1/deletions_controller_test.rb b/test/functional/api/v1/deletions_controller_test.rb index 63d3d116c60..9be60bb1bea 100644 --- a/test/functional/api/v1/deletions_controller_test.rb +++ b/test/functional/api/v1/deletions_controller_test.rb @@ -1,13 +1,35 @@ require "test_helper" class Api::V1::DeletionsControllerTest < ActionController::TestCase + include ActiveJob::TestHelper + context "with yank rubygem api key scope" do setup do - api_key = create(:api_key, key: "12345", yank_rubygem: true) - @user = api_key.user + @api_key = create(:api_key, key: "12345", scopes: %i[yank_rubygem]) + @user = @api_key.user @request.env["HTTP_AUTHORIZATION"] = "12345" end + context "with a gem version that is the suffix of another gem name" do + setup do + @owner = create(:user) + @rubygem = create(:rubygem, name: "some-gem") + @v1 = create(:version, rubygem: @rubygem, number: "0.1.0", platform: "ruby") + @ownership = create(:ownership, user: @owner, rubygem: @rubygem) + @user_gem = create(:rubygem, name: "some") + @user_v1 = create(:version, rubygem: @user_gem, number: "0.1.0", platform: "ruby") + @user_own = create(:ownership, user: @user, rubygem: @user_gem) + RubygemFs.instance.store("gems/#{@v1.full_name}.gem", "") + end + + context "ON DELETE" do + setup do + delete :create, params: { gem_name: "some", version: "gem-0.1.0" } + end + should respond_with :not_found + end + end + context "for a gem SomeGem with a version 0.1.0" do setup do @rubygem = create(:rubygem, name: "SomeGem") @@ -18,33 +40,84 @@ class Api::V1::DeletionsControllerTest < ActionController::TestCase context "when mfa for UI and API is enabled" do setup do - @user.enable_mfa!(ROTP::Base32.random_base32, :ui_and_api) + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) end context "ON DELETE to create for existing gem version without OTP" do setup do - delete :create, params: { gem_name: @rubygem.to_param, version: @v1.number } + delete :create, params: { gem_name: @rubygem.slug, version: @v1.number } end should respond_with :unauthorized + + should "return body that starts with MFA enabled message" do + assert @response.body.start_with?("You have enabled multifactor authentication") + end end context "ON DELETE to create for existing gem version with incorrect OTP" do setup do - @request.env["HTTP_OTP"] = (ROTP::TOTP.new(@user.mfa_seed).now.to_i.succ % 1_000_000).to_s - delete :create, params: { gem_name: @rubygem.to_param, version: @v1.number } + @request.env["HTTP_OTP"] = (ROTP::TOTP.new(@user.totp_seed).now.to_i.succ % 1_000_000).to_s + delete :create, params: { gem_name: @rubygem.slug, version: @v1.number } end should respond_with :unauthorized end context "ON DELETE to create for existing gem version with correct OTP" do setup do - @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.mfa_seed).now - delete :create, params: { gem_name: @rubygem.to_param, version: @v1.number } + @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.totp_seed).now + delete :create, params: { gem_name: @rubygem.slug, version: @v1.number } + end + should respond_with :success + should "keep the gem, deindex, keep owner" do + assert_equal 1, @rubygem.versions.count + assert_predicate @rubygem.versions.indexed.count, :zero? + end + should "record the deletion" do + assert_not_nil Deletion.where(user: @user, + rubygem: @rubygem.name, + number: @v1.number).first + end + end + end + + context "when mfa for UI only is enabled" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + end + + context "api key has mfa enabled" do + setup do + @api_key.mfa = true + @api_key.save! + delete :create, params: { gem_name: @rubygem.slug, version: @v1.number } + end + should respond_with :unauthorized + end + + context "api key does not have mfa enabled" do + setup do + delete :create, params: { gem_name: @rubygem.slug, version: @v1.number } + end + should respond_with :success + end + end + + context "when mfa is required in metadata" do + setup do + @v1.metadata = { "rubygems_mfa_required" => "true" } + @v1.save! + end + + context "when user has mfa enabled" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.totp_seed).now + delete :create, params: { gem_name: @rubygem.slug, version: @v1.number } end should respond_with :success should "keep the gem, deindex, keep owner" do assert_equal 1, @rubygem.versions.count - assert @rubygem.versions.indexed.count.zero? + assert_predicate @rubygem.versions.indexed.count, :zero? end should "record the deletion" do assert_not_nil Deletion.where(user: @user, @@ -52,17 +125,243 @@ class Api::V1::DeletionsControllerTest < ActionController::TestCase number: @v1.number).first end end + + context "when user has not mfa enabled" do + setup do + delete :create, params: { gem_name: @rubygem.slug, version: @v1.number } + end + should respond_with :forbidden + end + end + + context "with api key gem scoped" do + setup do + @api_key = create(:api_key, name: "gem-scoped-delete-key", key: "123456", scopes: %i[yank_rubygem], owner: @user, rubygem_id: @rubygem.id) + @request.env["HTTP_AUTHORIZATION"] = "123456" + end + + context "to the same gem to be deleted" do + setup do + delete :create, params: { gem_name: @rubygem.slug, version: @v1.number } + end + + should respond_with :success + end + + context "to another gem" do + setup do + ownership = create(:ownership, user: @user, rubygem: create(:rubygem, name: "another_gem")) + @api_key.update(ownership: ownership) + + delete :create, params: { gem_name: @rubygem.slug, version: @v1.number } + end + + should respond_with :forbidden + end + + context "to a gem with ownership removed" do + setup do + ownership = create(:ownership, user: create(:user), rubygem: create(:rubygem, name: "test-gem123")) + @api_key.update(ownership: ownership) + ownership.destroy! + + delete :create, params: { gem_name: @rubygem.slug, version: @v1.number } + end + + should respond_with :forbidden + + should "#render_soft_deleted_api_key and display an error" do + assert_equal "An invalid API key cannot be used. Please delete it and create a new one.", @response.body + end + end + end + + context "when mfa is required" do + setup do + User.any_instance.stubs(:mfa_required?).returns true + end + + context "by user with mfa disabled" do + setup do + delete :create, params: { gem_name: @rubygem.name, version: @v1.number } + end + + should respond_with :forbidden + + should "show error message" do + mfa_error = I18n.t("multifactor_auths.api.mfa_required_not_yet_enabled").chomp + + assert_includes @response.body, mfa_error + end + end + + context "by user on `ui_only` level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + delete :create, params: { gem_name: @rubygem.name, version: @v1.number } + end + + should respond_with :forbidden + + should "show error message" do + mfa_error = I18n.t("multifactor_auths.api.mfa_required_weak_level_enabled").chomp + + assert_includes @response.body, mfa_error + end + end + + context "by user on `ui_and_gem_signin` level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_gem_signin) + delete :create, params: { gem_name: @rubygem.name, version: @v1.number } + end + + should respond_with :success + + should "not show error message" do + refute_includes @response.body, "For protection of your account and your gems" + end + end + + context "by user on `ui_and_api` level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.totp_seed).now + delete :create, params: { gem_name: @rubygem.name, version: @v1.number } + end + + should respond_with :success + + should "not show error message" do + refute_includes @response.body, "For protection of your account and your gems" + end + end + end + + context "when mfa is required by metadata and user downloads" do + setup do + User.any_instance.stubs(:mfa_required?).returns true + @v1.metadata = { "rubygems_mfa_required" => "true" } + @v1.save! + end + + context "by user with mfa disabled" do + setup do + delete :create, params: { gem_name: @rubygem.name, version: @v1.number } + end + + should "only render one forbidden response" do + assert_equal 403, @response.status + end + end + end + + context "when mfa is recommended" do + setup do + User.any_instance.stubs(:mfa_recommended?).returns true + + another_gem = create(:rubygem, name: "gem_owned_by_someone_else") + create(:version, rubygem: another_gem, number: "0.1.1", platform: "ruby") + + v2 = create(:version, rubygem: @rubygem, number: "0.1.1", platform: "ruby") + Deletion.create!(user: @user, version: v2) + + @gems = { + success: { name: @rubygem.slug, version: @v1.number, deletion_status: :success }, + already_deleted: { name: @rubygem.slug, version: v2.number, deletion_status: :unprocessable_content }, + not_owned_gem: { name: another_gem.slug, version: @v1.number, deletion_status: :forbidden }, + without_version: { name: create(:rubygem).name, deletion_status: :not_found } + } + end + + context "by user with mfa disabled" do + should "include mfa setup warning" do + @gems.each_value do |gem| + delete :create, params: { gem_name: gem[:name], version: gem[:version] } + + assert_response gem[:deletion_status] + mfa_warning = "\n\n#{I18n.t('multifactor_auths.api.mfa_recommended_not_yet_enabled')}".chomp + + assert_includes @response.body, mfa_warning + end + end + end + + context "by user on `ui_only` mfa level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + end + + should "include change mfa level warning" do + @gems.each_value do |gem| + delete :create, params: { gem_name: gem[:name], version: gem[:version] } + + assert_response gem[:deletion_status] + mfa_warning = "\n\n#{I18n.t('multifactor_auths.api.mfa_recommended_weak_level_enabled')}".chomp + + assert_includes @response.body, mfa_warning + end + end + end + + context "by user on `ui_and_gem_signin` mfa level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_gem_signin) + end + + should "not include mfa warnings" do + @gems.each_value do |gem| + @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.totp_seed).now + delete :create, params: { gem_name: gem[:name], version: gem[:version] } + + assert_response gem[:deletion_status] + refute_includes @response.body, I18n.t("multifactor_auths.api.mfa_recommended_not_yet_enabled").chomp + refute_includes @response.body, I18n.t("multifactor_auths.api.mfa_recommended_weak_level_enabled").chomp + end + end + end + + context "by user on `ui_and_api` mfa level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + end + + should "not include mfa warnings" do + @gems.each_value do |gem| + @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.totp_seed).now + delete :create, params: { gem_name: gem[:name], version: gem[:version] } + + assert_response gem[:deletion_status] + refute_includes @response.body, I18n.t("multifactor_auths.api.mfa_recommended_not_yet_enabled").chomp + refute_includes @response.body, I18n.t("multifactor_auths.api.mfa_recommended_weak_level_enabled").chomp + end + end + end + end + + context "with a soft deleted api key" do + setup do + @api_key.soft_delete! + + delete :create, params: { gem_name: @rubygem.slug, version: @v1.number } + end + + should respond_with :forbidden + + should "#render_soft_deleted_api_key and display an error" do + assert_equal "An invalid API key cannot be used. Please delete it and create a new one.", @response.body + end end context "ON DELETE to create for existing gem version" do setup do create(:global_web_hook, user: @user, url: "http://example.org") - delete :create, params: { gem_name: @rubygem.to_param, version: @v1.number } + delete :create, params: { gem_name: @rubygem.slug, version: @v1.number } end should respond_with :success should "keep the gem, deindex, keep owner" do assert_equal 1, @rubygem.versions.count - assert @rubygem.versions.indexed.count.zero? + assert_predicate @rubygem.versions.indexed.count, :zero? end should "record the deletion" do assert_not_nil Deletion.where(user: @user, @@ -70,7 +369,13 @@ class Api::V1::DeletionsControllerTest < ActionController::TestCase number: @v1.number).first end should "have enqueued a webhook" do - assert_instance_of Notifier, Delayed::Job.last.payload_object + assert_enqueued_jobs 1, only: NotifyWebHookJob + end + should "have enqueued reindexing job" do + assert_enqueued_jobs 1, only: Indexer + assert_enqueued_jobs 1, only: UploadVersionsFileJob + assert_enqueued_jobs 1, only: UploadNamesFileJob + assert_enqueued_with job: UploadInfoFileJob, args: [{ rubygem_name: @rubygem.name }] end end @@ -81,7 +386,7 @@ class Api::V1::DeletionsControllerTest < ActionController::TestCase context "ON DELETE to create for version 0.1.1" do setup do - delete :create, params: { gem_name: @rubygem.to_param, version: @v2.number } + delete :create, params: { gem_name: @rubygem.slug, version: @v2.number } end should respond_with :success should "keep the gem, deindex it, and keep the owners" do @@ -104,7 +409,7 @@ class Api::V1::DeletionsControllerTest < ActionController::TestCase context "ON DELETE to create for version 0.1.1 and x86-darwin-10" do setup do - delete :create, params: { gem_name: @rubygem.to_param, version: @v2.number, platform: @v2.platform } + delete :create, params: { gem_name: @rubygem.slug, version: @v2.number, platform: @v2.platform } end should respond_with :success should "keep the gem, deindex it, and keep the owners" do @@ -128,7 +433,7 @@ class Api::V1::DeletionsControllerTest < ActionController::TestCase context "ON DELETE to create for existing gem with invalid version" do setup do - delete :create, params: { gem_name: @rubygem.to_param, version: "0.2.0" } + delete :create, params: { gem_name: @rubygem.slug, version: "0.2.0" } end should respond_with :not_found should "not modify any versions" do @@ -146,9 +451,10 @@ class Api::V1::DeletionsControllerTest < ActionController::TestCase other_rubygem = create(:rubygem, name: "SomeOtherGem") create(:version, rubygem: other_rubygem, number: "0.1.0", platform: "ruby") create(:ownership, user: other_user, rubygem: other_rubygem) - delete :create, params: { gem_name: other_rubygem.to_param, version: "0.1.0" } + delete :create, params: { gem_name: other_rubygem.slug, version: "0.1.0" } end should respond_with :forbidden + should "not record the deletion" do assert_equal 0, @user.deletions.count end @@ -157,9 +463,10 @@ class Api::V1::DeletionsControllerTest < ActionController::TestCase context "ON DELETE to create for an already deleted gem" do setup do Deletion.create!(user: @user, version: @v1) - delete :create, params: { gem_name: @rubygem.to_param, version: @v1.number } + delete :create, params: { gem_name: @rubygem.slug, version: @v1.number } end - should respond_with :unprocessable_entity + should respond_with :unprocessable_content + should "not re-record the deletion" do assert_equal 1, Deletion.where(user: @user, rubygem: @rubygem.name, @@ -167,6 +474,102 @@ class Api::V1::DeletionsControllerTest < ActionController::TestCase end end end + + context "rubygem with no versions" do + setup do + @rubygem = create(:rubygem, name: "no_versions") + @ownership = create(:ownership, user: @user, rubygem: @rubygem) + end + + context "ON DELETE to create for non existent version" do + setup do + delete :create, params: { gem_name: @rubygem.slug, version: "0.1.0" } + end + should respond_with :not_found + + should "not respond with not found message" do + assert_equal "This rubygem could not be found.", @response.body + end + should "not record the deletion" do + assert_empty Deletion.where(user: @user, rubygem: @rubygem.name, number: "0.1.0") + end + end + end + + context "rubygem that is deletion ineligible" do + context "with too many downloads" do + setup do + @rubygem = create(:rubygem, name: "SomeGem") + @v1 = create(:version, rubygem: @rubygem, number: "0.1.0", platform: "ruby") + @ownership = create(:ownership, user: @user, rubygem: @rubygem) + + GemDownload.increment( + 100_001, + rubygem_id: @rubygem.id, + version_id: @v1.id + ) + delete :create, params: { gem_name: @rubygem.slug, version: @v1.number } + end + + should respond_with :forbidden + + should "respond with a message" do + assert_equal( + "Versions with more than 100,000 downloads cannot be deleted. " \ + "Please contact RubyGems support (support@rubygems.org) to request deletion of this version if it represents a legal or security risk.", + @response.body + ) + end + should "not record the deletion" do + assert_empty Deletion.where(user: @user, rubygem: @rubygem.name, number: @v1.number) + end + should "record a yank forbidden event" do + assert_event Events::RubygemEvent::VERSION_YANK_FORBIDDEN, { + number: @v1.number, + platform: "ruby", + yanked_by: @user.handle, + version_gid: @v1.to_gid_param, + actor_gid: @user.to_gid.to_s, + reason: "Versions with more than 100,000 downloads cannot be deleted." + }, @rubygem.events.where(tag: Events::RubygemEvent::VERSION_YANK_FORBIDDEN).sole + end + end + + context "published too long ago" do + setup do + travel_to 31.days.ago do + @rubygem = create(:rubygem, name: "SomeGem") + @v1 = create(:version, rubygem: @rubygem, number: "0.1.0", platform: "ruby") + @ownership = create(:ownership, user: @user, rubygem: @rubygem) + end + + delete :create, params: { gem_name: @rubygem.slug, version: @v1.number } + end + + should respond_with :forbidden + + should "respond with a message" do + assert_equal( + "Versions published more than 30 days ago cannot be deleted. " \ + "Please contact RubyGems support (support@rubygems.org) to request deletion of this version if it represents a legal or security risk.", + @response.body + ) + end + should "not record the deletion" do + assert_empty Deletion.where(user: @user, rubygem: @rubygem.name, number: @v1.number) + end + should "record a yank forbidden event" do + assert_event Events::RubygemEvent::VERSION_YANK_FORBIDDEN, { + number: @v1.number, + platform: "ruby", + yanked_by: @user.handle, + version_gid: @v1.to_gid_param, + actor_gid: @user.to_gid.to_s, + reason: "Versions published more than 30 days ago cannot be deleted." + }, @rubygem.events.where(tag: Events::RubygemEvent::VERSION_YANK_FORBIDDEN).sole + end + end + end end context "without yank rubygem api key scope" do @@ -175,9 +578,13 @@ class Api::V1::DeletionsControllerTest < ActionController::TestCase @request.env["HTTP_AUTHORIZATION"] = "12342" rubygem = create(:rubygem, number: "1.0.0", owners: [api_key.user]) - delete :create, params: { gem_name: rubygem.to_param, version: "1.0.0" } + delete :create, params: { gem_name: rubygem.slug, version: "1.0.0" } end should respond_with :forbidden + + should "return body that starts with denied access message" do + assert_equal "This API key cannot perform the specified action on this gem.", @response.body + end end end diff --git a/test/functional/api/v1/dependencies_controller_test.rb b/test/functional/api/v1/dependencies_controller_test.rb index 27643459c10..6bd617269fd 100644 --- a/test/functional/api/v1/dependencies_controller_test.rb +++ b/test/functional/api/v1/dependencies_controller_test.rb @@ -1,213 +1,51 @@ require "test_helper" class Api::V1::DependenciesControllerTest < ActionController::TestCase - ## JSON ENDPOINTS: - # NO GEMS: - context "On GET to index --> with empty gems param --> JSON" do - setup do - get :index, params: { gems: "" }, format: "json" - end - - should "return 200" do - assert_response :success - end - - should "return an empty body" do - assert_empty response.body - end - end - - context "On GET to index --> with no gems param --> JSON" do - setup do - get :index, format: "json" - end - - should "return 200" do - assert_response :success - end - - should "return an empty body" do - assert_empty response.body - end - end - - # INVALID GEMS: - context "On GET to index --> with hash in gems params --> JSON" do - setup do - get :index, params: { gems: { 0 => "a", 1 => "b" } }, format: "json" - end - - should "return 200" do - assert_response :success - end - - should "return an empty body" do - assert_empty response.body - end - end - - # WITH GEMS: - context "On GET to index --> with gems --> JSON" do - setup do - rubygem = create(:rubygem, name: "rails") - create(:version, number: "1.0.0", rubygem_id: rubygem.id) - get :index, params: { gems: "rails" }, format: "json" - end - - should "return 200" do - assert_response :success - end - - should "return body" do - result = [{ - "name" => "rails", - "number" => "1.0.0", - "platform" => "ruby", - "dependencies" => [] - }] - - assert_equal result, JSON.load(response.body) - end - end - - # WITH COMPLEX GEMS: - context "on GET to index --> with complex gems --> JSON" do - setup do - rubygem1 = create(:rubygem, name: "myrails") - rubygem2 = create(:rubygem, name: "mybundler") - create(:version, number: "1.0.0", rubygem_id: rubygem1.id) - create(:version, number: "2.0.0", rubygem_id: rubygem2.id) - create(:version, number: "3.0.0", rubygem_id: rubygem1.id) - get :index, params: { gems: "myrails,mybundler" }, format: "json" - end - - should "return 200" do - assert_response :success - end - - should "return surrogate key header" do - assert_equal "dependencyapi gem/myrails gem/mybundler", @response.headers["Surrogate-Key"] - end - - should "return body" do - result = [ - { - "name" => "myrails", - "number" => "1.0.0", - "platform" => "ruby", - "dependencies" => [] - }, - - { - "name" => "myrails", - "number" => "3.0.0", - "platform" => "ruby", - "dependencies" => [] - }, - - { - "name" => "mybundler", - "number" => "2.0.0", - "platform" => "ruby", - "dependencies" => [] + ## BROWNOUT / DEPRECATION: + context "On GET to index -> during brownout range" do + context "with empty gems param --> JSON" do + should "return 404" do + get :index, params: { gems: "" }, format: "json" + + assert_response :not_found + result = { + "error" => "The dependency API has gone away. See " \ + "https://blog.rubygems.org/2023/02/22/dependency-api-deprecation.html " \ + "for more information", + "code" => 404 } - ] - - assert_same_elements result, JSON.load(response.body) - end - end - - # TOO MANY GEMS: - context "On GET to index --> with gems --> JSON" do - setup do - gems = Array.new(300) { create(:rubygem) }.join(",") - get :index, params: { gems: gems }, format: "json" - end - - should "return 422" do - assert_response :unprocessable_entity - end - - should "return an error body" do - result = { - "error" => "Too many gems! (use --full-index instead)", - "code" => 422 - } - - assert_equal result, JSON.load(response.body) - end - end - - ## MARSHAL ENDPOINTS: - # NO GEMS: - context "On GET to index --> with no gems --> Marshal" do - setup do - rubygem = create(:rubygem, name: "testgem") - @version = create(:version, number: "1.0.0", rubygem_id: rubygem.id) - get :index, params: { gems: "" }, format: "marshal" - end - - should "return 200" do - assert_response :success - end - should "return an empty body" do - assert_empty response.body + assert_equal result, JSON.load(response.body) + end end - end - # INVALID GEMS: - context "On GET to index --> with array in gems params --> Marshal" do - setup do - get :index, params: { gems: %w[a b] }, format: "marshal" - end - - should "return 200" do - assert_response :success - end + context "with gems param and Accept --> JSON" do + should "return 404" do + request.headers["Accept"] = "application/json" + get :index, params: { gems: "testgem" } - should "return an empty body" do - assert_empty response.body - end - end - - # WITH GEMS: - context "On GET to index --> with gems --> Marshal" do - setup do - rubygem = create(:rubygem, name: "testgem") - create(:version, number: "1.0.0", rubygem_id: rubygem.id) - get :index, params: { gems: "testgem" }, format: "marshal" - end - - should "return 200" do - assert_response :success - end - - should "return body" do - result = [{ - name: "testgem", - number: "1.0.0", - platform: "ruby", - dependencies: [] - }] - - assert_equal result, Marshal.load(response.body) - end - end + assert_response :not_found + result = { + "error" => "The dependency API has gone away. See " \ + "https://blog.rubygems.org/2023/02/22/dependency-api-deprecation.html " \ + "for more information", + "code" => 404 + } - # TOO MANY GEMS: - context "On GET to index --> with gems --> Marshal" do - setup do - gems = Array.new(300) { create(:rubygem) }.join(",") - get :index, params: { gems: gems }, format: "marshal" + assert_equal result, JSON.load(response.body) + end end - should "return 422" do - assert_response :unprocessable_entity - end + context "with gems --> Marshal" do + should "return 404" do + get :index, params: { gems: "testgem" }, format: "marshal" - should "return an error body" do - assert_equal "Too many gems! (use --full-index instead)", response.body + assert_response :not_found + assert_equal "The dependency API has gone away. See " \ + "https://blog.rubygems.org/2023/02/22/dependency-api-deprecation.html " \ + "for more information", + response.body + end end end end diff --git a/test/functional/api/v1/downloads_controller_test.rb b/test/functional/api/v1/downloads_controller_test.rb index bc14e85eb23..d8e121e4832 100644 --- a/test/functional/api/v1/downloads_controller_test.rb +++ b/test/functional/api/v1/downloads_controller_test.rb @@ -4,6 +4,7 @@ class Api::V1::DownloadsControllerTest < ActionController::TestCase def self.should_respond_to(format) should "return #{format.to_s.upcase} with the download count" do get :index, format: format + assert_equal @count, yield(@response.body) end end @@ -16,6 +17,7 @@ def self.should_respond_to(format) should "return the download count" do get :index + assert_equal @count, @response.body.to_i end @@ -24,7 +26,7 @@ def self.should_respond_to(format) end should_respond_to(:yaml) do |body| - YAML.safe_load(body, [Symbol])[:total] + YAML.safe_load(body, permitted_classes: [Symbol])[:total] end should_respond_to(:text, &:to_i) @@ -38,21 +40,25 @@ def self.should_respond_to(format, to_meth = :to_s) context "with #{format.to_s.upcase}" do should "have total downloads for version1" do get_show(@version1, format) + assert_equal 3, yield(@response.body)["total_downloads".send(to_meth)] end should "have downloads for the most recent version of version1" do get_show(@version1, format) + assert_equal 1, yield(@response.body)["version_downloads".send(to_meth)] end should "have total downloads for version2" do get_show(@version2, format) + assert_equal 3, yield(@response.body)["total_downloads".send(to_meth)] end should "have downloads for the most recent version of version2" do get_show(@version2, format) + assert_equal 2, yield(@response.body)["version_downloads".send(to_meth)] end end @@ -72,7 +78,7 @@ def self.should_respond_to(format, to_meth = :to_s) end should_respond_to(:yaml, :to_sym) do |body| - YAML.safe_load(body, [Symbol]) + YAML.safe_load(body, permitted_classes: [Symbol]) end end diff --git a/test/functional/api/v1/owners_controller_test.rb b/test/functional/api/v1/owners_controller_test.rb index 92cc26bf127..e1164f85882 100644 --- a/test/functional/api/v1/owners_controller_test.rb +++ b/test/functional/api/v1/owners_controller_test.rb @@ -1,12 +1,16 @@ require "test_helper" class Api::V1::OwnersControllerTest < ActionController::TestCase + include ActiveJob::TestHelper + include ActionMailer::TestHelper + def self.should_respond_to(format) should "route GET show with #{format.to_s.upcase}" do route = { controller: "api/v1/owners", action: "show", rubygem_id: "rails", format: format.to_s } + assert_recognizes(route, "/api/v1/gems/rails/owners.#{format}") end @@ -17,24 +21,21 @@ def self.should_respond_to(format) @other_user = create(:user) create(:ownership, rubygem: @rubygem, user: @user) - get :show, params: { rubygem_id: @rubygem.to_param }, format: format + get :show, params: { rubygem_id: @rubygem.slug }, format: format end should "return an array" do response = yield(@response.body) - assert_kind_of Array, response - end - should "return correct owner email" do - assert_equal @user.email, yield(@response.body)[0]["email"] + assert_kind_of Array, response end should "return correct owner handle" do assert_equal @user.handle, yield(@response.body)[0]["handle"] end - should "not return other owner email" do - assert yield(@response.body).map { |owner| owner["email"] }.exclude?(@other_user.email) + should "not return other owner handle" do + assert yield(@response.body).pluck("handle").exclude?(@other_user.handle) end end end @@ -62,7 +63,7 @@ def self.should_respond_to(format) end should "return plaintext with error message" do - assert_equal @response.body, "Owner could not be found." + assert_equal("Owner could not be found.", @response.body) end should respond_with :not_found @@ -71,6 +72,14 @@ def self.should_respond_to(format) context "on GET to owner gems with id" do setup do @user = create(:user) + rubygem = create(:rubygem, owners: [@user]) + version = create(:version, rubygem: rubygem) + rubygem2 = create(:rubygem, owners: [@user]) + rubygem3 = create(:rubygem, owners: [@user]) + version2 = create(:version, rubygem: rubygem2) + create(:dependency, version: version, rubygem: rubygem2, requirements: ">= 0", scope: "runtime") + create(:dependency, version: version, rubygem: rubygem3, requirements: ">= 0", scope: "development") + create(:dependency, version: version2, rubygem: rubygem3, requirements: ">= 0", scope: "runtime") get :gems, params: { handle: @user.id }, format: :json end @@ -84,20 +93,38 @@ def self.should_respond_to(format) end should "return plain text with error message" do - assert_equal @response.body, "Owner could not be found." + assert_equal("Owner could not be found.", @response.body) end should respond_with :not_found end - should "route POST" do + should "route POST /api/v1/gems/rubygem/owners.json" do route = { controller: "api/v1/owners", action: "create", rubygem_id: "rails", format: "json" } + assert_recognizes(route, path: "/api/v1/gems/rails/owners.json", method: :post) end + should "route POST /api/v1/gems/rubygem/owners.yaml" do + route = { controller: "api/v1/owners", + action: "create", + rubygem_id: "rails", + format: "yaml" } + + assert_recognizes(route, path: "/api/v1/gems/rails/owners.yaml", method: :post) + end + + should "route POST /api/v1/gems/rubygem/owners" do + route = { controller: "api/v1/owners", + action: "create", + rubygem_id: "rails" } + + assert_recognizes(route, path: "/api/v1/gems/rails/owners", method: :post) + end + context "on POST to owner gem" do context "with add owner api key scope" do setup do @@ -105,63 +132,106 @@ def self.should_respond_to(format) @user = create(:user) @second_user = create(:user) @third_user = create(:user) - create(:ownership, rubygem: @rubygem, user: @user) - create(:api_key, key: "12334", add_owner: true, user: @user) + @ownership = create(:ownership, rubygem: @rubygem, user: @user) + @api_key = create(:api_key, key: "12334", scopes: %i[add_owner], owner: @user) @request.env["HTTP_AUTHORIZATION"] = "12334" end context "when mfa for UI and API is enabled" do setup do - @user.enable_mfa!(ROTP::Base32.random_base32, :ui_and_api) + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + end + + context "array of emails" do + setup do + @third_user = create(:user) + @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.totp_seed).now + post :create, params: { rubygem_id: @rubygem.slug, email: [@second_user.email, @third_user.email] } + end + + should respond_with :bad_request + should "fail to add new owner" do + refute_includes @rubygem.owners_including_unconfirmed, @second_user + refute_includes @rubygem.owners_including_unconfirmed, @third_user + end end context "adding other user as gem owner without OTP" do setup do - post :create, params: { rubygem_id: @rubygem.to_param, email: @second_user.email }, format: :json + post :create, params: { rubygem_id: @rubygem.slug, email: @second_user.email } end should respond_with :unauthorized + should "fail to add new owner" do - refute @rubygem.owners_including_unconfirmed.include?(@second_user) + refute_includes @rubygem.owners_including_unconfirmed, @second_user + end + should "return body that starts with MFA enabled message" do + assert @response.body.start_with?("You have enabled multifactor authentication") end end context "adding other user as gem owner with incorrect OTP" do setup do - @request.env["HTTP_OTP"] = (ROTP::TOTP.new(@user.mfa_seed).now.to_i.succ % 1_000_000).to_s - post :create, params: { rubygem_id: @rubygem.to_param, email: @second_user.email }, format: :json + @request.env["HTTP_OTP"] = (ROTP::TOTP.new(@user.totp_seed).now.to_i.succ % 1_000_000).to_s + post :create, params: { rubygem_id: @rubygem.slug, email: @second_user.email } end should respond_with :unauthorized + should "fail to add new owner" do - refute @rubygem.owners_including_unconfirmed.include?(@second_user) + refute_includes @rubygem.owners_including_unconfirmed, @second_user end end context "adding other user as gem owner with correct OTP" do setup do - @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.mfa_seed).now - post :create, params: { rubygem_id: @rubygem.to_param, email: @second_user.email }, format: :json + @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.totp_seed).now + post :create, params: { rubygem_id: @rubygem.slug, email: @second_user.email } end should respond_with :success + should "succeed to add new owner" do - assert @rubygem.owners_including_unconfirmed.include?(@second_user) + assert_includes @rubygem.owners_including_unconfirmed, @second_user end end end + context "when mfa for UI only is enabled" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + end + + context "api key has mfa enabled" do + setup do + @api_key.mfa = true + @api_key.save! + post :create, params: { rubygem_id: @rubygem.slug, email: @second_user.email } + end + should respond_with :unauthorized + end + + context "api key does not have mfa enabled" do + setup do + post :create, params: { rubygem_id: @rubygem.slug, email: @second_user.email } + end + should respond_with :success + end + end + context "when mfa for UI and API is disabled" do context "add user with email" do setup do - post :create, params: { rubygem_id: @rubygem.to_param, email: @second_user.email }, format: :json - Delayed::Worker.new.work_off + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + post :create, params: { rubygem_id: @rubygem.slug, email: @second_user.email } + end end should "add second user as unconfrimed owner" do - assert @rubygem.owners_including_unconfirmed.include?(@second_user) - assert_equal "#{@second_user.handle} was added as an unconfirmed owner. "\ - "Ownership access will be enabled after the user clicks on the confirmation mail sent to their email.", @response.body + assert_includes @rubygem.owners_including_unconfirmed, @second_user + assert_equal "#{@second_user.handle} was added as an unconfirmed owner. " \ + "Ownership access will be enabled after the user clicks on the confirmation mail sent to their email.", @response.body end should "send confirmation mail to second user" do @@ -172,18 +242,18 @@ def self.should_respond_to(format) context "add user with handler" do setup do - post :create, params: { rubygem_id: @rubygem.to_param, email: @second_user.handle }, format: :json + post :create, params: { rubygem_id: @rubygem.slug, email: @second_user.handle } end should "add other user as gem owner" do - assert @rubygem.owners_including_unconfirmed.include?(@second_user) + assert_includes @rubygem.owners_including_unconfirmed, @second_user end end end context "user is not found" do setup do - post :create, params: { rubygem_id: @rubygem.to_param, email: "doesnot@exist.com" } + post :create, params: { rubygem_id: @rubygem.slug, email: "doesnot@exist.com" } end should respond_with :not_found @@ -191,12 +261,295 @@ def self.should_respond_to(format) context "owner already exists" do setup do - post :create, params: { rubygem_id: @rubygem.to_param, email: @user.email } + post :create, params: { rubygem_id: @rubygem.slug, email: @user.email } end - should respond_with :unprocessable_entity + should respond_with :unprocessable_content + should "respond with error message" do - assert_equal "User has already been taken", @response.body + assert_equal "User is already an owner of this gem", @response.body + end + end + + context "owner has already been invited" do + setup do + post :create, params: { rubygem_id: @rubygem.slug, email: @second_user.email } + post :create, params: { rubygem_id: @rubygem.slug, email: @second_user.email } + end + + should respond_with :unprocessable_content + + should "respond with error message" do + assert_equal "User is already invited to this gem", @response.body + end + end + + context "when mfa is required by gem" do + setup do + metadata = { "rubygems_mfa_required" => "true" } + create(:version, rubygem: @rubygem, number: "1.0.0", metadata: metadata) + end + + context "api user has enabled mfa" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.totp_seed).now + end + + should "add other user as gem owner" do + post :create, params: { rubygem_id: @rubygem.slug, email: @second_user.email } + + assert_includes @rubygem.owners_including_unconfirmed, @second_user + end + end + + context "api user has not enabled mfa" do + setup do + post :create, params: { rubygem_id: @rubygem.slug, email: @second_user.email } + end + + should respond_with :forbidden + + should "refuse to add other user as gem owner" do + refute_includes @rubygem.owners_including_unconfirmed, @second_user + end + end + end + + context "when mfa is required by yanked gem" do + setup do + metadata = { "rubygems_mfa_required" => "true" } + create(:version, rubygem: @rubygem, number: "1.0.0", indexed: false, metadata: metadata) + + post :create, params: { rubygem_id: @rubygem.slug, email: @second_user.email } + end + + should respond_with :success + + should "add other user as gem owner" do + assert_includes @rubygem.owners_including_unconfirmed, @second_user + end + end + + context "with api key gem scoped" do + context "to another gem" do + setup do + another_rubygem_ownership = create(:ownership, user: @user, rubygem: create(:rubygem, name: "test")) + + @api_key.update(ownership: another_rubygem_ownership) + post :create, params: { rubygem_id: @rubygem.slug, email: @second_user.email } + end + + should respond_with :forbidden + + should "not add other user as gem owner" do + refute_includes @rubygem.owners, @second_user + end + end + + context "to the same gem" do + setup do + @api_key.update(rubygem_id: @rubygem.id) + post :create, params: { rubygem_id: @rubygem.slug, email: @second_user.email } + end + + should respond_with :success + + should "adds other user as gem owner" do + assert_includes @rubygem.owners_including_unconfirmed, @second_user + end + end + + context "to a gem with ownership removed" do + setup do + @api_key.update(ownership: @ownership) + @ownership.destroy! + + post :create, params: { rubygem_id: @rubygem.slug, email: @second_user.email } + end + + should respond_with :forbidden + + should "#render_soft_deleted_api_key and display an error" do + assert_equal "An invalid API key cannot be used. Please delete it and create a new one.", @response.body + end + end + end + + context "with a soft deleted api key" do + setup do + @api_key.soft_delete! + + post :create, params: { rubygem_id: @rubygem.slug, email: @second_user.email } + end + + should respond_with :forbidden + + should "#render_soft_deleted_api_key and display an error" do + assert_equal "An invalid API key cannot be used. Please delete it and create a new one.", @response.body + end + end + + context "when mfa is required" do + setup do + User.any_instance.stubs(:mfa_required?).returns true + @emails = [@second_user.email, "doesnotexist@example.com", @user.email] + end + + context "by user with mfa disabled" do + should "block adding the owner" do + @emails.each do |email| + post :create, params: { rubygem_id: @rubygem.slug, email: email } + + assert_equal 403, @response.status + mfa_error = I18n.t("multifactor_auths.api.mfa_required_not_yet_enabled").chomp + + assert_includes @response.body, mfa_error + end + end + end + + context "by user on `ui_only` level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + end + + should "block adding the owner" do + @emails.each do |email| + post :create, params: { rubygem_id: @rubygem.slug, email: email } + + assert_equal 403, @response.status + mfa_error = I18n.t("multifactor_auths.api.mfa_required_weak_level_enabled").chomp + + assert_includes @response.body, mfa_error + end + end + end + + context "by user on `ui_and_gem_signin` level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_gem_signin) + end + + should "not show error message" do + @emails.each do |email| + post :create, params: { rubygem_id: @rubygem.slug, email: email } + + refute_includes @response.body, "For protection of your account and your gems" + end + end + end + + context "by user on `ui_and_api` level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.totp_seed).now + end + + should "not show error message" do + @emails.each do |email| + post :create, params: { rubygem_id: @rubygem.slug, email: email } + + refute_includes @response.body, "For protection of your account and your gems" + end + end + end + end + + context "when mfa is recommended" do + setup do + User.any_instance.stubs(:mfa_recommended?).returns true + @emails = [@second_user.email, "doesnotexist@example.com", @user.email] + end + + context "by user with mfa disabled" do + should "include mfa setup warning" do + @emails.each do |email| + post :create, params: { rubygem_id: @rubygem.slug, email: email } + mfa_warning = "\n\n#{I18n.t('multifactor_auths.api.mfa_recommended_not_yet_enabled')}".chomp + + assert_includes @response.body, mfa_warning + end + end + end + + context "by user on `ui_only` level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + end + + should "include change mfa level warning" do + @emails.each do |email| + post :create, params: { rubygem_id: @rubygem.slug, email: email } + mfa_warning = "\n\n#{I18n.t('multifactor_auths.api.mfa_recommended_weak_level_enabled')}".chomp + + assert_includes @response.body, mfa_warning + end + end + end + + context "by user on `ui_and_gem_signin` level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_gem_signin) + end + + should "not include MFA warnings" do + @emails.each do |email| + post :create, params: { rubygem_id: @rubygem.slug, email: email } + + refute_includes @response.body, I18n.t("multifactor_auths.api.mfa_recommended_not_yet_enabled").chomp + refute_includes @response.body, I18n.t("multifactor_auths.api.mfa_recommended_weak_level_enabled").chomp + end + end + end + + context "by user on `ui_and_api` level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.totp_seed).now + end + + should "not include mfa warnings" do + @emails.each do |email| + post :create, params: { rubygem_id: @rubygem.slug, email: email } + + refute_includes @response.body, I18n.t("multifactor_auths.api.mfa_recommended_not_yet_enabled").chomp + refute_includes @response.body, I18n.t("multifactor_auths.api.mfa_recommended_weak_level_enabled").chomp + end + end + end + end + + context "when not supplying a role" do + should "set a default role" do + post :create, params: { rubygem_id: @rubygem.slug, email: @second_user.display_id } + + assert_equal 200, @response.status + assert_predicate Ownership.find_by(user: @second_user, rubygem: @rubygem), :owner? + end + end + + context "given a role" do + should "set the role for the given user" do + post :create, params: { rubygem_id: @rubygem.slug, email: @second_user.display_id, role: :maintainer } + + assert_equal 200, @response.status + assert_predicate Ownership.find_by(user: @second_user, rubygem: @rubygem), :maintainer? + end + end + + context "when given an invalid role" do + should "raise an error" do + post :create, params: { rubygem_id: @rubygem.slug, email: @second_user.display_id, role: :invalid } + + assert_equal 422, @response.status + assert_equal "Role is not included in the list", @response.body + end + + should "not create the ownership" do + post :create, params: { rubygem_id: @rubygem.slug, email: @second_user.email, role: :invalid } + + assert_nil @rubygem.ownerships.find_by(user: @second_user) end end end @@ -207,71 +560,99 @@ def self.should_respond_to(format) rubygem = create(:rubygem, owners: [api_key.user]) @request.env["HTTP_AUTHORIZATION"] = "12323" - post :create, params: { rubygem_id: rubygem.to_param, email: "some@email.com" }, format: :json + post :create, params: { rubygem_id: rubygem.slug, email: "some@email.com" } end should respond_with :forbidden + + should "return body with denied access message" do + assert_equal "This API key cannot perform the specified action on this gem.", @response.body + end end end - should "route DELETE" do + should "route DELETE /api/v1/gems/gemname/owners.json" do route = { controller: "api/v1/owners", action: "destroy", rubygem_id: "rails", format: "json" } + assert_recognizes(route, path: "/api/v1/gems/rails/owners.json", method: :delete) end + should "route DELETE /api/v1/gems/gemname/owners.yaml" do + route = { controller: "api/v1/owners", + action: "destroy", + rubygem_id: "rails", + format: "yaml" } + + assert_recognizes(route, path: "/api/v1/gems/rails/owners.yaml", method: :delete) + end + + should "route DELETE /api/v1/gems/gemname/owners" do + route = { controller: "api/v1/owners", + action: "destroy", + rubygem_id: "rails" } + + assert_recognizes(route, path: "/api/v1/gems/rails/owners", method: :delete) + end + context "on DELETE to owner gem" do context "with remove owner api key scope" do setup do @rubygem = create(:rubygem) @user = create(:user) @second_user = create(:user) - create(:ownership, rubygem: @rubygem, user: @user) + @ownership = create(:ownership, rubygem: @rubygem, user: @user) @ownership = create(:ownership, rubygem: @rubygem, user: @second_user) - create(:api_key, key: "12223", remove_owner: true, user: @user) + @api_key = create(:api_key, key: "12223", scopes: %i[remove_owner], owner: @user) @request.env["HTTP_AUTHORIZATION"] = "12223" end context "when mfa for UI and API is enabled" do setup do - @user.enable_mfa!(ROTP::Base32.random_base32, :ui_and_api) + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) end context "removing gem owner without OTP" do setup do - delete :destroy, params: { rubygem_id: @rubygem.to_param, email: @second_user.email, format: :json } + delete :destroy, params: { rubygem_id: @rubygem.slug, email: @second_user.email } end should respond_with :unauthorized + should "fail to remove gem owner" do - assert @rubygem.owners.include?(@second_user) + assert_includes @rubygem.owners, @second_user + end + should "return body that starts with MFA enabled message" do + assert @response.body.start_with?("You have enabled multifactor authentication") end end context "removing gem owner with incorrect OTP" do setup do - @request.env["HTTP_OTP"] = (ROTP::TOTP.new(@user.mfa_seed).now.to_i.succ % 1_000_000).to_s - delete :destroy, params: { rubygem_id: @rubygem.to_param, email: @second_user.email, format: :json } + @request.env["HTTP_OTP"] = (ROTP::TOTP.new(@user.totp_seed).now.to_i.succ % 1_000_000).to_s + delete :destroy, params: { rubygem_id: @rubygem.slug, email: @second_user.email } end should respond_with :unauthorized + should "fail to remove gem owner" do - assert @rubygem.owners.include?(@second_user) + assert_includes @rubygem.owners, @second_user end end context "removing gem owner with correct OTP" do setup do - @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.mfa_seed).now - delete :destroy, params: { rubygem_id: @rubygem.to_param, email: @second_user.email, format: :json } + @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.totp_seed).now + delete :destroy, params: { rubygem_id: @rubygem.slug, email: @second_user.email } end should respond_with :success + should "succeed to remove gem owner" do - refute @rubygem.owners.include?(@second_user) + refute_includes @rubygem.owners, @second_user end end end @@ -279,12 +660,13 @@ def self.should_respond_to(format) context "when mfa for UI and API is disabled" do context "user is not the only confirmed owner" do setup do - delete :destroy, params: { rubygem_id: @rubygem.to_param, email: @second_user.email, format: :json } - Delayed::Worker.new.work_off + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + delete :destroy, params: { rubygem_id: @rubygem.slug, email: @second_user.email } + end end should "remove user as gem owner" do - refute @rubygem.owners.include?(@second_user) + refute_includes @rubygem.owners, @second_user assert_equal "Owner removed successfully.", @response.body end @@ -297,15 +679,243 @@ def self.should_respond_to(format) context "user is the only confirmed owner" do setup do @ownership.destroy - delete :destroy, params: { rubygem_id: @rubygem.to_param, email: @user.email, format: :json } + delete :destroy, params: { rubygem_id: @rubygem.slug, email: @user.email } end should "not remove last gem owner" do - assert @rubygem.owners.include?(@user) + assert_includes @rubygem.owners, @user assert_equal "Unable to remove owner.", @response.body end end end + + context "when mfa is required by gem version" do + setup do + metadata = { "rubygems_mfa_required" => "true" } + create(:version, rubygem: @rubygem, number: "1.0.0", metadata: metadata) + end + + context "api user hasi not enabled mfa" do + setup do + delete :destroy, params: { rubygem_id: @rubygem.slug, email: @second_user.email } + end + + should respond_with :forbidden + + should "fail to remove gem owner" do + assert_includes @rubygem.owners, @second_user + end + end + + context "api user has enabled mfa" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.totp_seed).now + end + + context "on delete to remove gem owner with correct OTP" do + setup do + @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.totp_seed).now + delete :destroy, params: { rubygem_id: @rubygem.slug, email: @second_user.email } + end + + should respond_with :success + + should "succeed to remove gem owner" do + refute_includes @rubygem.owners, @second_user + end + end + end + end + + context "with api key gem scoped" do + context "to another gem" do + setup do + another_rubygem_ownership = create(:ownership, user: @user, rubygem: create(:rubygem, name: "test")) + + @api_key.update(ownership: another_rubygem_ownership) + post :destroy, params: { rubygem_id: @rubygem.slug, email: @second_user.email } + end + + should respond_with :forbidden + should "not remove other user as gem owner" do + assert_includes @rubygem.owners, @second_user + assert_equal "This API key cannot perform the specified action on this gem.", @response.body + end + end + + context "to the same gem" do + setup do + @api_key.update(rubygem_id: @rubygem.id) + post :destroy, params: { rubygem_id: @rubygem.slug, email: @second_user.email } + end + + should respond_with :success + + should "removes other user as gem owner" do + refute_includes @rubygem.owners, @second_user + end + end + + context "to a gem with ownership removed" do + setup do + @api_key.update(ownership: @ownership) + @ownership.destroy! + + post :destroy, params: { rubygem_id: @rubygem.slug, email: @second_user.email } + end + + should respond_with :forbidden + + should "#render_soft_deleted_api_key and display an error" do + assert_equal "An invalid API key cannot be used. Please delete it and create a new one.", @response.body + end + end + end + + context "with a soft deleted api key" do + setup do + @api_key.soft_delete! + + post :destroy, params: { rubygem_id: @rubygem.slug, email: @second_user.email } + end + + should respond_with :forbidden + + should "#render_soft_deleted_api_key and display an error" do + assert_equal "An invalid API key cannot be used. Please delete it and create a new one.", @response.body + end + end + + context "when mfa is required" do + setup do + User.any_instance.stubs(:mfa_required?).returns true + @emails = [@second_user.email, "doesnotexist@example.com", @user.email, "no@permission.com"] + end + + context "by user with mfa disabled" do + should "block adding the owner" do + @emails.each do |email| + delete :destroy, params: { rubygem_id: @rubygem.slug, email: email } + + assert_equal 403, response.status + mfa_error = I18n.t("multifactor_auths.api.mfa_required_not_yet_enabled").chomp + + assert_includes @response.body, mfa_error + end + end + end + + context "by user on `ui_only` level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + end + + should "block adding the owner" do + @emails.each do |email| + delete :destroy, params: { rubygem_id: @rubygem.slug, email: email } + + assert_equal 403, @response.status + mfa_error = I18n.t("multifactor_auths.api.mfa_required_weak_level_enabled").chomp + + assert_includes @response.body, mfa_error + end + end + end + + context "by user on `ui_and_gem_signin` level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_gem_signin) + end + + should "not show error message" do + @emails.each do |email| + delete :destroy, params: { rubygem_id: @rubygem.slug, email: email } + + refute_includes @response.body, "For protection of your account and your gems" + end + end + end + + context "by user on `ui_and_api` level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.totp_seed).now + end + + should "not show error message" do + @emails.each do |email| + delete :destroy, params: { rubygem_id: @rubygem.slug, email: email } + + refute_includes @response.body, "For protection of your account and your gems" + end + end + end + end + + context "when mfa is recommended" do + setup do + User.any_instance.stubs(:mfa_recommended?).returns true + @emails = [@second_user.email, "doesnotexist@example.com", @user.email, "nopermission@example.com"] + end + + context "by user with mfa disabled" do + should "include mfa setup warning" do + @emails.each do |email| + delete :destroy, params: { rubygem_id: @rubygem.slug, email: email } + mfa_warning = "\n\n#{I18n.t('multifactor_auths.api.mfa_recommended_not_yet_enabled')}".chomp + + assert_includes @response.body, mfa_warning + end + end + end + + context "by user on `ui_only` level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + end + + should "include change mfa level warning" do + @emails.each do |email| + delete :destroy, params: { rubygem_id: @rubygem.slug, email: email } + mfa_warning = "\n\n#{I18n.t('multifactor_auths.api.mfa_recommended_weak_level_enabled')}".chomp + + assert_includes @response.body, mfa_warning + end + end + end + + context "by user on `ui_and_gem_signin` level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_gem_signin) + end + + should "not include mfa warnings" do + @emails.each do |email| + delete :destroy, params: { rubygem_id: @rubygem.slug, email: email } + + refute_includes @response.body, I18n.t("multifactor_auths.api.mfa_recommended_not_yet_enabled").chomp + refute_includes @response.body, I18n.t("multifactor_auths.api.mfa_recommended_weak_level_enabled").chomp + end + end + end + + context "by user on `ui_and_api` level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.totp_seed).now + end + + should "not include mfa warnings" do + @emails.each do |email| + delete :destroy, params: { rubygem_id: @rubygem.slug, email: email } + + refute_includes @response.body, I18n.t("multifactor_auths.api.mfa_recommended_not_yet_enabled").chomp + refute_includes @response.body, I18n.t("multifactor_auths.api.mfa_recommended_weak_level_enabled").chomp + end + end + end + end end context "without remove owner api key scope" do @@ -314,26 +924,117 @@ def self.should_respond_to(format) rubygem = create(:rubygem, owners: [api_key.user]) @request.env["HTTP_AUTHORIZATION"] = "12342" - delete :destroy, params: { rubygem_id: rubygem.to_param, email: "some@owner.com" }, format: :json + delete :destroy, params: { rubygem_id: rubygem.slug, email: "some@owner.com" } end should respond_with :forbidden + + should "return body that has the denied access message" do + assert_equal "This API key cannot perform the specified action on this gem.", @response.body + end end end - should "route GET gems" do + should "route GET /api/v1/owners/username/gems.json" do route = { controller: "api/v1/owners", action: "gems", handle: "example", format: "json" } + assert_recognizes(route, path: "/api/v1/owners/example/gems.json", method: :get) end + should "route GET /api/v1/owners/username/gems.yaml" do + route = { controller: "api/v1/owners", + action: "gems", + handle: "example", + format: "yaml" } + + assert_recognizes(route, path: "/api/v1/owners/example/gems.yaml", method: :get) + end + should "return plain text 404 error" do - create(:api_key, key: "12223", add_owner: true) + create(:api_key, key: "12223", scopes: %i[add_owner]) @request.env["HTTP_AUTHORIZATION"] = "12223" @request.accept = "*/*" post :create, params: { rubygem_id: "bananas" } + assert_equal "This rubygem could not be found.", @response.body end + + should "route PUT /api/v1/gems/rubygem/owners.yaml" do + route = { controller: "api/v1/owners", + action: "update", + rubygem_id: "rails", + format: "yaml" } + + assert_recognizes(route, path: "/api/v1/gems/rails/owners.yaml", method: :put) + end + + context "on PATCH to owner gem" do + setup do + @owner = create(:user) + @maintainer = create(:user) + @rubygem = create(:rubygem, owners: [@owner]) + + @api_key = create(:api_key, key: "12223", scopes: %i[update_owner], owner: @owner, rubygem: @rubygem) + @request.env["HTTP_AUTHORIZATION"] = "12223" + end + + should "set the maintainer to a lower access level" do + ownership = create(:ownership, user: @maintainer, rubygem: @rubygem, role: :owner) + + patch :update, params: { rubygem_id: @rubygem.slug, email: @maintainer.email, role: :maintainer } + + assert_response :success + assert_predicate ownership.reload, :maintainer? + assert_enqueued_email_with OwnersMailer, :owner_updated, params: { ownership: ownership } + end + + context "when the current user is changing their own role" do + should "forbid changing the role" do + patch :update, params: { rubygem_id: @rubygem.slug, email: @owner.email, role: :maintainer } + + ownership = @rubygem.ownerships.find_by(user: @owner) + + assert_response :forbidden + assert_predicate ownership.reload, :owner? + end + end + + context "when the role is invalid" do + should "return a bad request response with the error message" do + ownership = create(:ownership, user: @maintainer, rubygem: @rubygem, role: :maintainer) + + patch :update, params: { rubygem_id: @rubygem.slug, email: @maintainer.email, role: :invalid } + + assert_response :unprocessable_entity + assert_equal "Role is not included in the list", @response.body + assert_predicate ownership.reload, :maintainer? + end + end + + context "when the owner is not found" do + context "when the update is authorized" do + should "return a not found response" do + patch :update, params: { rubygem_id: @rubygem.slug, email: "notauser", role: :owner } + + assert_response :not_found + assert_equal "Owner could not be found.", @response.body + end + end + + context "when the update is not authorized" do + should "return a forbidden response" do + @api_key = create(:api_key, key: "99999", scopes: %i[push_rubygem], owner: @owner) + @request.env["HTTP_AUTHORIZATION"] = "99999" + + patch :update, params: { rubygem_id: @rubygem.slug, email: "notauser", role: :owner } + + assert_response :forbidden + assert_equal "This API key cannot perform the specified action on this gem.", @response.body + end + end + end + end end diff --git a/test/functional/api/v1/profiles_controller_test.rb b/test/functional/api/v1/profiles_controller_test.rb index 475d3a4d62d..b39f0d32969 100644 --- a/test/functional/api/v1/profiles_controller_test.rb +++ b/test/functional/api/v1/profiles_controller_test.rb @@ -15,7 +15,21 @@ def to_yaml(body) end def response_body - send("to_#{@format}", @response.body) + send(:"to_#{@format}", @response.body) + end + + def authorize_with(str) + @request.env["HTTP_AUTHORIZATION"] = "Basic #{Base64.encode64(str)}" + end + + def assert_mfa_info_included(mfa_level) + assert response_body.key?("mfa") + assert_match mfa_level, @response.body + end + + def refute_mfa_info_included(mfa_level) + refute response_body.key?("mfa") + refute_match mfa_level, @response.body end %i[json yaml].each do |format| @@ -30,6 +44,10 @@ def response_body end should respond_with :success + + should "not return owner mfa information by default" do + refute_mfa_info_included @user.mfa_level + end end context "on GET to show with handle" do @@ -38,21 +56,115 @@ def response_body end should respond_with :success - should "include the user email" do - assert response_body.key?("email") - assert_equal @user.email, response_body["email"] + + should "hide the user email by default" do + refute response_body.key?("email") + end + + should "not return owner mfa information by default" do + refute_mfa_info_included @user.mfa_level end end - context "on GET to show when hide email" do + context "on GET to me with authentication" do setup do - @user.update(hide_email: true) + @user = create(:user) + authorize_with("#{@user.email}:#{@user.password}") + get :me, format: format + end + + should respond_with :success + + should "return owner mfa information" do + assert_mfa_info_included @user.mfa_level + end + + context "when mfa is recommended" do + setup do + rubygem = create(:rubygem) + create(:ownership, user: @user, rubygem: rubygem) + GemDownload.increment( + Rubygem::MFA_RECOMMENDED_THRESHOLD + 1, + rubygem_id: rubygem.id + ) + get :me, format: format + end + + context "when mfa is disabled" do + should "include warning" do + expected_warning = I18n.t("multifactor_auths.api.mfa_recommended_not_yet_enabled").chomp + + assert_includes response_body["warning"].to_s, expected_warning + end + end + + context "when mfa is enabled" do + context "on `ui_only` level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + get :me, format: format + end + + should "include warning" do + expected_warning = I18n.t("multifactor_auths.api.mfa_recommended_weak_level_enabled").chomp + + assert_includes response_body["warning"].to_s, expected_warning + end + end + + context "on `ui_and_gem_signin` level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_gem_signin) + get :me, format: format + end + + should "not include warning in user json" do + unexpected_warning = "For protection of your account and gems" + + refute_includes response_body["warning"].to_s, unexpected_warning + end + end + + context "on `ui_and_api` level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + get :me, format: format + end + + should "not include warning" do + unexpected_warning = "For protection of your account and gems" + + refute_includes response_body["warning"].to_s, unexpected_warning + end + end + end + end + end + + context "on GET to me with bad creds" do + setup do + @user = create(:user) + authorize_with("bad:creds") + get :me, format: format + end + + should "deny access" do + assert_response :unauthorized + assert_match "Invalid credentials", @response.body + end + end + + context "on GET to show when email is public" do + setup do + @user.update(public_email: true) get :show, params: { id: @user.handle }, format: format end should respond_with :success - should "hide the user email" do - refute response_body.key?("email") + + should "include the user email" do + assert response_body.key?("email") + assert_equal @user.email, response_body["email"] end should "shows the handle" do diff --git a/test/functional/api/v1/rubygems_controller_test.rb b/test/functional/api/v1/rubygems_controller_test.rb index dcc51db6a0f..cf60b05c4c4 100644 --- a/test/functional/api/v1/rubygems_controller_test.rb +++ b/test/functional/api/v1/rubygems_controller_test.rb @@ -1,11 +1,15 @@ require "test_helper" class Api::V1::RubygemsControllerTest < ActionController::TestCase + include ActiveJob::TestHelper + should "route old paths to new controller" do get_route = { controller: "api/v1/rubygems", action: "show", id: "rails", format: "json" } + assert_recognizes(get_route, "/api/v1/gems/rails.json") post_route = { controller: "api/v1/rubygems", action: "create" } + assert_recognizes(post_route, path: "/api/v1/gems", method: :post) end @@ -13,40 +17,31 @@ def self.should_respond_to_show should respond_with :success should "return a hash" do response = yield(@response.body) if block_given? + assert_not_nil response assert_kind_of Hash, response end end - def self.should_respond_to(format, &block) + def self.should_respond_to(format, &blk) context "with #{format.to_s.upcase} for a hosted gem" do setup do @rubygem = create(:rubygem) create(:version, rubygem: @rubygem) - get :show, params: { id: @rubygem.to_param }, format: format + get :show, params: { id: @rubygem.slug }, format: format end - should_respond_to_show(&block) + should_respond_to_show(&blk) end context "with #{format.to_s.upcase} for a hosted gem with a period in its name" do setup do @rubygem = create(:rubygem, name: "foo.rb") create(:version, rubygem: @rubygem) - get :show, params: { id: @rubygem.to_param }, format: format + get :show, params: { id: @rubygem.slug }, format: format end - should_respond_to_show(&block) - end - - context "with #{format.to_s.upcase} for a gem that doesn't match the slug" do - setup do - @rubygem = create(:rubygem, name: "ZenTest", slug: "zentest") - create(:version, rubygem: @rubygem) - get :show, params: { id: "ZenTest" }, format: format - end - - should_respond_to_show(&block) + should_respond_to_show(&blk) end end @@ -69,11 +64,13 @@ def self.should_respond_to(format, &block) context "On GET to show for a gem that not hosted" do setup do @rubygem = create(:rubygem) - assert @rubygem.versions.count.zero? - get :show, params: { id: @rubygem.to_param }, format: "json" + + assert_predicate @rubygem.versions.count, :zero? + get :show, params: { id: @rubygem.slug }, format: "json" end should respond_with :not_found + should "say gem could not be found" do assert_equal "This rubygem could not be found.", @response.body end @@ -82,11 +79,13 @@ def self.should_respond_to(format, &block) context "On GET to show for a gem that doesn't exist" do setup do @name = generate(:name) + refute Rubygem.exists?(name: @name) get :show, params: { id: @name }, format: "json" end should respond_with :not_found + should "say the rubygem was not found" do assert_match(/not be found/, @response.body) end @@ -96,10 +95,11 @@ def self.should_respond_to(format, &block) setup do @rubygem = create(:rubygem) create(:version, rubygem: @rubygem, number: "1.0.0", indexed: false) - get :show, params: { id: @rubygem.to_param }, format: "json" + get :show, params: { id: @rubygem.slug }, format: "json" end should respond_with :not_found + should "say gem could not be found" do assert_equal "This rubygem could not be found.", @response.body end @@ -116,7 +116,7 @@ def self.should_respond_to(format, &block) @missing_dependency.rubygem.update_column(:name, "missing") @missing_dependency.update_column(:rubygem_id, nil) - get :show, params: { id: @rubygem.to_param }, format: "json" + get :show, params: { id: @rubygem.slug }, format: "json" end should respond_with :success @@ -129,7 +129,7 @@ def self.should_respond_to(format, &block) context "CORS" do setup do - rubygem = create(:rubygem, name: "ZenTest", slug: "zentest") + rubygem = create(:rubygem, name: "ZenTest") create(:version, rubygem: rubygem) end @@ -174,11 +174,13 @@ def self.should_respond_to(format) end should respond_with :success + should "return a hash" do assert_not_nil yield(@response.body) end should "only return my gems" do - gem_names = yield(@response.body).map { |rubygem| rubygem["name"] }.sort + gem_names = yield(@response.body).pluck("name").sort + assert_equal %w[AnotherGem SomeGem], gem_names end end @@ -186,7 +188,8 @@ def self.should_respond_to(format) context "with index and push rubygem api key scope" do setup do - @user = create(:api_key, key: "12345", push_rubygem: true, index_rubygems: true).user + @api_key = create(:api_key, key: "12345", scopes: %i[push_rubygem index_rubygems]) + @user = @api_key.user @request.env["HTTP_AUTHORIZATION"] = "12345" end @@ -203,28 +206,32 @@ def self.should_respond_to(format) context "When mfa for UI and API is enabled" do setup do - @user.enable_mfa!(ROTP::Base32.random_base32, :ui_and_api) + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) end context "On post to create for new gem without OTP" do setup do - post :create, body: gem_file.read + post :create, body: gem_file(&:read) end should respond_with :unauthorized + + should "return body that starts with MFA enabled message" do + assert @response.body.start_with?("You have enabled multifactor authentication") + end end context "On post to create for new gem with incorrect OTP" do setup do - @request.env["HTTP_OTP"] = (ROTP::TOTP.new(@user.mfa_seed).now.to_i.succ % 1_000_000).to_s - post :create, body: gem_file.read + @request.env["HTTP_OTP"] = (ROTP::TOTP.new(@user.totp_seed).now.to_i.succ % 1_000_000).to_s + post :create, body: gem_file(&:read) end should respond_with :unauthorized end context "On post to create for new gem with correct OTP" do setup do - @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.mfa_seed).now - post :create, body: gem_file.read + @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.totp_seed).now + post :create, body: gem_file(&:read) end should respond_with :success should "register new gem" do @@ -237,12 +244,21 @@ def self.should_respond_to(format) context "When mfa for UI and gem signin is enabled" do setup do - @user.enable_mfa!(ROTP::Base32.random_base32, :ui_and_gem_signin) + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_gem_signin) + end + + context "Api key has mfa enabled" do + setup do + @api_key.mfa = true + @api_key.save! + post :create, body: gem_file(&:read) + end + should respond_with :unauthorized end context "On POST to create for new gem" do setup do - post :create, body: gem_file.read + post :create, body: gem_file(&:read) end should respond_with :success should "register new gem" do @@ -254,14 +270,14 @@ def self.should_respond_to(format) ownership = Rubygem.last.ownerships.first assert_equal @user, ownership.user - assert ownership.confirmed? + assert_predicate ownership, :confirmed? end end end context "On POST to create for new gem" do setup do - post :create, body: gem_file.read + post :create, body: gem_file(&:read) end should respond_with :success should "register new gem" do @@ -273,7 +289,7 @@ def self.should_respond_to(format) ownership = Rubygem.last.ownerships.first assert_equal @user, ownership.user - assert ownership.confirmed? + assert_predicate ownership, :confirmed? end end @@ -286,19 +302,29 @@ def self.should_respond_to(format) create(:version, rubygem: rubygem, number: "0.0.0", updated_at: 1.year.ago, created_at: 1.year.ago) end should "respond_with success" do - post :create, body: gem_file("test-1.0.0.gem").read + post :create, body: gem_file("test-1.0.0.gem", &:read) + assert_response :success end should "register new version" do - post :create, body: gem_file("test-1.0.0.gem").read + post :create, body: gem_file("test-1.0.0.gem", &:read) + assert_equal @user, Rubygem.last.ownerships.first.user assert_equal 1, Rubygem.last.ownerships.count assert_equal 2, Rubygem.last.versions.count assert_equal "Successfully registered gem: test (1.0.0)", @response.body end should "enqueue jobs" do - assert_difference "Delayed::Job.count", 7 do - post :create, body: gem_file("test-1.0.0.gem").read + assert_enqueued_jobs 1, only: ActionMailer::MailDeliveryJob do + assert_enqueued_jobs 6, only: FastlyPurgeJob do + assert_enqueued_jobs 1, only: NotifyWebHookJob do + assert_enqueued_jobs 1, only: Indexer do + assert_enqueued_jobs 1, only: ReindexRubygemJob do + post :create, body: gem_file("test-1.0.0.gem", &:read) + end + end + end + end end end end @@ -309,8 +335,8 @@ def self.should_respond_to(format) rubygem = create(:rubygem, name: "test") create(:ownership, :unconfirmed, rubygem: rubygem, user: @user) create(:version, rubygem: rubygem, number: "0.0.0", updated_at: 1.year.ago, created_at: 1.year.ago) - assert_difference "Delayed::Job.count", 0 do - post :create, body: gem_file("test-1.0.0.gem").read + assert_no_enqueued_jobs do + post :create, body: gem_file("test-1.0.0.gem", &:read) end end should respond_with :forbidden @@ -332,12 +358,13 @@ def self.should_respond_to(format) authors: ["Geddy Lee"], built_at: @date) - post :create, body: gem_file.read + post :create, body: gem_file(&:read) end should respond_with :conflict should "not register new version" do - version = Rubygem.last.reload.versions.most_recent - assert_equal @date.to_s(:db), version.built_at.to_s(:db), "(date)" + version = Rubygem.last.reload.most_recent_version + + assert_equal @date.to_fs(:db), version.built_at.to_fs(:db), "(date)" assert_equal "Freewill", version.summary, "(summary)" assert_equal "Geddy Lee", version.authors, "(authors)" end @@ -347,9 +374,9 @@ def self.should_respond_to(format) setup do post :create, body: "really bad gem" end - should respond_with :unprocessable_entity + should respond_with :unprocessable_content should "not register gem" do - assert Rubygem.count.zero? + assert_predicate Rubygem.count, :zero? assert_match(/RubyGems\.org cannot process this gem/, @response.body) end end @@ -357,8 +384,8 @@ def self.should_respond_to(format) context "On POST to create with an underscore or dash variant of an existing gem" do setup do existing = create(:rubygem, name: "t_es-t", downloads: 3002) - existing.versions.create(number: "1.0.0", platform: "ruby") - post :create, body: gem_file("test-1.0.0.gem").read + create(:version, rubygem: existing, number: "1.0.0", platform: "ruby") + post :create, body: gem_file("test-1.0.0.gem", &:read) end should respond_with :forbidden @@ -374,25 +401,27 @@ def self.should_respond_to(format) @rubygem = create(:rubygem, name: "test", number: "0.0.0", owners: [@other_user]) create(:global_web_hook, user: @user, url: "http://example.org") - post :create, body: gem_file("test-1.0.0.gem").read + assert_no_enqueued_jobs do + post :create, body: gem_file("test-1.0.0.gem", &:read) + end end should respond_with 403 should "not allow new version to be saved" do assert_equal 1, @rubygem.ownerships.size assert_equal @other_user, @rubygem.ownerships.first.user assert_equal 1, @rubygem.versions.size - assert_equal 0, Delayed::Job.count assert_includes @response.body, "You do not have permission to push to this gem." end end context "On POST to create with reserved gem name" do setup do + create(:gem_name_reservation, name: "rubygems") post :create, body: gem_file("rubygems-0.1.0.gem").read end should respond_with 403 should "not register gem" do - assert Rubygem.count.zero? + assert_predicate Rubygem.count, :zero? assert_match(/There was a problem saving your gem: Name 'rubygems' is a reserved gem name./, @response.body) end end @@ -412,7 +441,8 @@ def self.should_respond_to(format) should "POST to create for existing gem should not fail" do requires_toxiproxy Toxiproxy[:elasticsearch].down do - post :create, body: gem_file("test-1.0.0.gem").read + post :create, body: gem_file("test-1.0.0.gem", &:read) + assert_response :success assert_equal @user, Rubygem.last.ownerships.first.user assert_equal 1, Rubygem.last.ownerships.count @@ -423,13 +453,290 @@ def self.should_respond_to(format) end end + context "push to create with mfa required" do + setup do + @user = create(:api_key, key: "12345", scopes: %w[push_rubygem]).user + @request.env["HTTP_AUTHORIZATION"] = "12345" + end + + context "new gem without MFA enabled" do + setup do + post :create, body: gem_file("mfa-required-1.0.0.gem", &:read) + end + should respond_with :forbidden + end + + context "new gem with correct OTP" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.totp_seed).now + post :create, body: gem_file("mfa-required-1.0.0.gem", &:read) + end + should respond_with :success + should "register new gem" do + assert_equal 1, Rubygem.count + assert_equal @user, Rubygem.last.ownerships.first.user + assert_equal "Successfully registered gem: mfa_required (1.0.0)", @response.body + end + end + + context "for existing gem" do + setup do + rubygem = create(:rubygem, name: "mfa_required") + create(:ownership, rubygem: rubygem, user: @user) + create(:version, rubygem: rubygem, number: "0.0.0") + end + + context "by user without mfa" do + setup do + post :create, body: gem_file("mfa-required-1.0.0.gem", &:read) + end + + should respond_with :forbidden + end + + context "by user with mfa" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.totp_seed).now + post :create, body: gem_file("mfa-required-1.0.0.gem", &:read) + end + + should respond_with :success + should "register new version" do + assert_equal 1, Rubygem.count + assert_equal 2, Rubygem.last.versions.count + end + end + end + + context "rubygems_mfa_required already enabled" do + setup do + @rubygem = create(:rubygem, name: "test") + create(:ownership, rubygem: @rubygem, user: @user) + create(:version, rubygem: @rubygem, number: "0.0.0", metadata: { "rubygems_mfa_required" => "true" }) + end + + context "by user without mfa" do + setup do + post :create, body: gem_file("test-1.0.0.gem", &:read) + end + + should respond_with :forbidden + + should "show error message" do + assert_equal "Rubygem requires owners to enable MFA. You must enable MFA before pushing new version.", @response.body + end + end + + context "by user with mfa" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.totp_seed).now + post :create, body: gem_file("test-1.0.0.gem", &:read) + end + + should respond_with :success + should "register new version" do + assert_equal 1, Rubygem.count + assert_equal 2, Rubygem.last.versions.count + end + should "disable mfa requirement" do + refute_predicate @rubygem, :metadata_mfa_required? + end + end + end + + context "when mfa is required" do + setup do + User.any_instance.stubs(:mfa_required?).returns true + end + + context "by user with mfa disabled" do + setup do + post :create, body: gem_file("test-1.0.0.gem", &:read) + end + + should respond_with :forbidden + + should "show error message" do + mfa_error = I18n.t("multifactor_auths.api.mfa_required_not_yet_enabled").chomp + + assert_includes @response.body, mfa_error + end + end + + context "by user on `ui_only` level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + post :create, body: gem_file("test-1.0.0.gem", &:read) + end + + should respond_with :forbidden + + should "show error message" do + mfa_error = I18n.t("multifactor_auths.api.mfa_required_weak_level_enabled").chomp + + assert_includes @response.body, mfa_error + end + end + + context "by user on `ui_and_gem_signin` level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_gem_signin) + post :create, body: gem_file("test-1.0.0.gem", &:read) + end + + should respond_with :success + + should "not show error message" do + refute_includes @response.body, I18n.t("multifactor_auths.api.mfa_required_not_yet_enabled").chomp + refute_includes @response.body, I18n.t("multifactor_auths.api.mfa_required_weak_level_enabled").chomp + end + end + + context "by user on `ui_and_api` level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.totp_seed).now + post :create, body: gem_file("test-1.0.0.gem", &:read) + end + + should respond_with :success + + should "not show error message" do + refute_includes @response.body, I18n.t("multifactor_auths.api.mfa_required_not_yet_enabled").chomp + refute_includes @response.body, I18n.t("multifactor_auths.api.mfa_required_weak_level_enabled").chomp + end + end + end + + context "when mfa is recommended" do + setup do + User.any_instance.stubs(:mfa_recommended?).returns true + end + + context "by user with mfa disabled" do + setup do + post :create, body: gem_file("test-1.0.0.gem", &:read) + end + + should "include mfa setup warning" do + assert_includes @response.body, I18n.t("multifactor_auths.api.mfa_recommended_not_yet_enabled").chomp + end + end + + context "by user on `ui_only` mfa level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + post :create, body: gem_file("test-1.0.0.gem", &:read) + end + + should "include change mfa level warning" do + assert_includes @response.body, I18n.t("multifactor_auths.api.mfa_recommended_weak_level_enabled").chomp + end + end + + context "by user on `ui_and_gem_signin` mfa level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_gem_signin) + @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.totp_seed).now + post :create, body: gem_file("test-1.0.0.gem", &:read) + end + + should respond_with :success + should "not include mfa warning" do + refute_includes @response.body, I18n.t("multifactor_auths.api.mfa_recommended_not_yet_enabled").chomp + refute_includes @response.body, I18n.t("multifactor_auths.api.mfa_recommended_weak_level_enabled").chomp + end + end + + context "by user on `ui_and_api` mfa level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + @request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.totp_seed).now + post :create, body: gem_file("test-1.0.0.gem", &:read) + end + + should respond_with :success + should "not include mfa warning" do + refute_includes @response.body, I18n.t("multifactor_auths.api.mfa_recommended_not_yet_enabled").chomp + refute_includes @response.body, I18n.t("multifactor_auths.api.mfa_recommended_weak_level_enabled").chomp + end + end + end + end + + context "push with api key with gem scoped" do + context "to a gem with ownership removed" do + setup do + ownership = create(:ownership, user: create(:user), rubygem: create(:rubygem, name: "test-gem123")) + @api_key = create(:api_key, key: "12343", owner: ownership.user, ownership: ownership, scopes: %i[push_rubygem]) + ownership.destroy! + @request.env["HTTP_AUTHORIZATION"] = "12343" + + post :create, body: gem_file("test-1.0.0.gem", &:read) + end + + should respond_with :forbidden + + should "#render_soft_deleted_api_key and display an error" do + assert_equal "An invalid API key cannot be used. Please delete it and create a new one.", @response.body + end + end + + context "to a different gem" do + setup do + ownership = create(:ownership, user: create(:user), rubygem: create(:rubygem, name: "test-gem")) + create(:api_key, key: "12343", owner: ownership.user, ownership: ownership, scopes: %i[push_rubygem]) + @request.env["HTTP_AUTHORIZATION"] = "12343" + + post :create, body: gem_file("test-1.0.0.gem", &:read) + end + + should respond_with :forbidden + + should "say gem scope is invalid" do + assert_equal "This API key cannot perform the specified action on this gem.", @response.body + end + end + + context "to the gem being pushed" do + setup do + ownership = create(:ownership, user: create(:user), rubygem: create(:rubygem, name: "test")) + create(:api_key, key: "12343", owner: ownership.user, ownership: ownership, scopes: %i[push_rubygem]) + @request.env["HTTP_AUTHORIZATION"] = "12343" + + post :create, body: gem_file("test-1.0.0.gem", &:read) + end + + should respond_with :ok + end + end + + context "create with a soft deleted api key" do + setup do + create(:ownership, user: create(:user), rubygem: create(:rubygem, name: "test")) + create(:api_key, key: "12343", scopes: %i[push_rubygem]).soft_delete! + @request.env["HTTP_AUTHORIZATION"] = "12343" + + post :create, body: gem_file("test-1.0.0.gem", &:read) + end + + should respond_with :forbidden + + should "#render_soft_deleted_api_key and display an error" do + assert_equal "An invalid API key cannot be used. Please delete it and create a new one.", @response.body + end + end + context "with incorrect api key" do context "on GET to index with JSON for a list of gems without api key" do setup do get :index, format: "json" end should "deny access" do - assert_response 401 + assert_response :unauthorized assert_equal "Access Denied. Please sign up for an account at https://rubygems.org", @response.body end @@ -437,7 +744,7 @@ def self.should_respond_to(format) context "on GET to index without index rubygem scope" do setup do - create(:api_key, key: "12345", index_rubygems: false, push_rubygem: true) + create(:api_key, key: "12345", scopes: %i[push_rubygem]) @request.env["HTTP_AUTHORIZATION"] = "12345" get :index, format: :json end @@ -450,9 +757,13 @@ def self.should_respond_to(format) create(:api_key, key: "12343") @request.env["HTTP_AUTHORIZATION"] = "12343" - post :create, body: gem_file("test-1.0.0.gem").read + post :create, body: gem_file("test-1.0.0.gem", &:read) end should respond_with :forbidden + + should "return body that includes the denied access message" do + assert_includes @response.body, "This API key cannot perform the specified action on this gem." + end end end @@ -488,44 +799,50 @@ def self.should_respond_to(format) end should "return names of reverse dependencies" do - get :reverse_dependencies, params: { id: @dependency.to_param }, format: "json" + get :reverse_dependencies, params: { id: @dependency.slug }, format: "json" + + assert_response :success gems = JSON.load(@response.body) assert_equal 3, gems.size - assert gems.include?(@gem_one.name) - assert gems.include?(@gem_two.name) - assert gems.include?(@gem_three.name) + assert_includes gems, @gem_one.name + assert_includes gems, @gem_two.name + assert_includes gems, @gem_three.name end context "with only=development" do should "only return names of reverse development dependencies" do get :reverse_dependencies, - params: { id: @dependency.to_param, + params: { id: @dependency.slug, only: "development", format: "json" } + assert_response :success + gems = JSON.load(@response.body) assert_equal 1, gems.size - assert gems.include?(@gem_two.name) + assert_includes gems, @gem_two.name end end context "with only=runtime" do should "only return names of reverse development dependencies" do get :reverse_dependencies, - params: { id: @dependency.to_param, + params: { id: @dependency.slug, only: "runtime", format: "json" } + assert_response :success + gems = JSON.load(@response.body) assert_equal 2, gems.size - assert gems.include?(@gem_one.name) - assert gems.include?(@gem_three.name) + assert_includes gems, @gem_one.name + assert_includes gems, @gem_three.name end end end diff --git a/test/functional/api/v1/searches_controller_test.rb b/test/functional/api/v1/searches_controller_test.rb index 7225feffbbc..cb70b5c1aff 100644 --- a/test/functional/api/v1/searches_controller_test.rb +++ b/test/functional/api/v1/searches_controller_test.rb @@ -1,7 +1,7 @@ require "test_helper" class Api::V1::SearchesControllerTest < ActionController::TestCase - include ESHelper + include SearchKickHelper def self.should_respond_to(format) context "with query=match and with #{format.to_s.upcase}" do @@ -10,11 +10,13 @@ def self.should_respond_to(format) end should respond_with :success + should "contain a hash" do assert_kind_of Hash, yield(@response.body).first end should "only include matching gems" do gems = yield(@response.body) + assert_equal 1, gems.size assert_equal "match", gems.first["name"] end @@ -26,6 +28,7 @@ def self.should_respond_to(format) end should respond_with :bad_request + should "explain failed request" do assert page.has_content?("Request is missing param 'query'") end @@ -48,5 +51,66 @@ def self.should_respond_to(format) should_respond_to(:yaml) do |body| YAML.safe_load body end + + context "with elasticsearch down" do + should "returns friendly error message" do + requires_toxiproxy + Toxiproxy[:elasticsearch].down do + get :show, params: { query: "other" }, format: :json + + assert_response :service_unavailable + assert_equal "Search is currently unavailable. Please try again later.", @response.body + end + end + end + + context "invalid query" do + should "returns friendly error message" do + get :show, params: { query: "AND other" }, format: :json + + assert_response :bad_request + assert_equal "Failed to parse search term: 'AND other'.", JSON.parse(@response.body)["error"] + end + end + end + + context "on GET to autocomplete with query=ma" do + setup do + @match1 = create(:rubygem, name: "match1") + @match2 = create(:rubygem, name: "match2") + @other = create(:rubygem, name: "other") + create(:version, rubygem: @match1) + create(:version, rubygem: @match2) + create(:version, rubygem: @other) + import_and_refresh + end + + context "with elasticsearch up" do + setup do + get :autocomplete, params: { query: "ma" } + @body = JSON.parse(response.body) + end + + should respond_with :success + should "return gems name" do + assert_equal 2, @body.size + assert_equal "match1", @body[0] + end + should "not contain other gems" do + assert_not @body.include?("other") + end + end + + context "with elasticsearch down" do + should "fallback to legacy search" do + requires_toxiproxy + Toxiproxy[:elasticsearch].down do + get :autocomplete, params: { query: "ot" } + + assert_response :success + assert_empty JSON.parse(@response.body) + end + end + end end end diff --git a/test/functional/api/v1/timeframe_versions_controller_test.rb b/test/functional/api/v1/timeframe_versions_controller_test.rb index 9844b17d4f7..44f1d968546 100644 --- a/test/functional/api/v1/timeframe_versions_controller_test.rb +++ b/test/functional/api/v1/timeframe_versions_controller_test.rb @@ -19,6 +19,7 @@ class Api::V1::TimeframeVersionsControllerTest < ActionController::TestCase } gems = JSON.parse @response.body + assert_equal 2, gems.length assert_equal "rails", gems[0]["name"] assert_equal @rails_version2.number, gems[0]["version"] @@ -35,7 +36,8 @@ class Api::V1::TimeframeVersionsControllerTest < ActionController::TestCase } gems = JSON.parse @response.body - assert_equal [], gems + + assert_empty gems end end @@ -47,7 +49,17 @@ class Api::V1::TimeframeVersionsControllerTest < ActionController::TestCase } assert_equal 400, response.status - assert response.body.include?("iso8601") + assert_includes response.body, "iso8601" + end + + should 'return a bad request with message when "to" is not primitive' do + get :index, format: :json, params: { + from: Time.zone.parse("2017-11-09").iso8601, + to: ["2017-11-12"] + } + + assert_equal 400, response.status + assert_includes response.body, "iso8601" end should 'return a bad request with message when "from" is invalid' do @@ -57,7 +69,7 @@ class Api::V1::TimeframeVersionsControllerTest < ActionController::TestCase } assert_equal 400, response.status - assert response.body.include?("iso8601") + assert_includes response.body, "iso8601" end should "return a bad request with message when the range exceeds the max allowed" do @@ -67,7 +79,7 @@ class Api::V1::TimeframeVersionsControllerTest < ActionController::TestCase } assert_equal 400, response.status - assert response.body.include?("query time range cannot exceed") + assert_includes response.body, "query time range cannot exceed" end should "return a bad request with message if from is after to" do @@ -77,7 +89,7 @@ class Api::V1::TimeframeVersionsControllerTest < ActionController::TestCase } assert_equal 400, response.status - assert response.body.include?("must be before the ending time parameter") + assert_includes response.body, "must be before the ending time parameter" end end @@ -86,7 +98,7 @@ class Api::V1::TimeframeVersionsControllerTest < ActionController::TestCase get :index, format: :json, params: { to: Time.zone.parse("2017-11-12").iso8601 } assert_equal 400, response.status - assert response.body.include?("missing") + assert_includes response.body, "missing" end should 'default to the current time if "to" is missing' do @@ -94,6 +106,7 @@ class Api::V1::TimeframeVersionsControllerTest < ActionController::TestCase @sinatra_version.save! get :index, format: :json, params: { from: Time.zone.now.advance(days: -5).iso8601 } gems = JSON.parse @response.body + assert_equal 1, gems.length assert_equal "sinatra", gems[0]["name"] end diff --git a/test/functional/api/v1/versions/downloads_controller_test.rb b/test/functional/api/v1/versions/downloads_controller_test.rb new file mode 100644 index 00000000000..d57545895fd --- /dev/null +++ b/test/functional/api/v1/versions/downloads_controller_test.rb @@ -0,0 +1,21 @@ +require "test_helper" + +class Api::V1::Versions::DownloadsControllerTest < ActionController::TestCase + context "on GET to index" do + setup do + @rubygem = create(:rubygem, number: "0.1.0") + get :index, params: { version_id: @rubygem.latest_version.number, format: "json" } + end + + should respond_with :gone + end + + context "on GET to search" do + setup do + @rubygem = create(:rubygem, number: "0.1.0") + get :search, params: { version_id: @rubygem.latest_version.number, format: "json" } + end + + should respond_with :gone + end +end diff --git a/test/functional/api/v1/versions_controller_test.rb b/test/functional/api/v1/versions_controller_test.rb index 42996e85d65..1ba38a4d096 100644 --- a/test/functional/api/v1/versions_controller_test.rb +++ b/test/functional/api/v1/versions_controller_test.rb @@ -9,8 +9,8 @@ def get_latest(rubygem, format = "json") get :latest, params: { id: rubygem.name, format: format } end - def get_reverse_dependencies(rubygem, options = { format: "json" }) - get :reverse_dependencies, options.merge(params: { id: rubygem.name }) + def get_reverse_dependencies(rubygem, format: "json") + get :reverse_dependencies, params: { id: rubygem.name, format: format } end def set_cache_header @@ -22,12 +22,14 @@ def self.should_respond_to(format) context "with #{format.to_s.upcase}" do should "have a list of versions for the first gem" do get_show(@rubygem, format) + assert_equal 3, yield(@response.body).size end should "be ordered by position with prereleases" do get_show(@rubygem, format) arr = yield(@response.body) + assert_equal "4.0.0", arr.first["number"] assert_equal "2.0.0", arr.second["number"] assert_equal "1.0.0.pre", arr.third["number"] @@ -36,6 +38,7 @@ def self.should_respond_to(format) should "be ordered by position" do get_show(@rubygem2, format) arr = yield(@response.body) + assert_equal "3.0.0", arr.first["number"] assert_equal "2.0.0", arr.second["number"] assert_equal "1.0.0", arr.third["number"] @@ -43,6 +46,7 @@ def self.should_respond_to(format) should "have a list of versions for the second gem" do get_show(@rubygem2, format) + assert_equal 3, yield(@response.body).size end end @@ -72,35 +76,42 @@ def self.should_respond_to(format) should "return Last-Modified header" do get_show(@rubygem) + assert_equal @response.headers["Last-Modified"], @rubygem.updated_at.httpdate end should "return surrogate key header" do get_show(@rubygem) + assert_equal "gem/#{@rubygem.name}", @response.headers["Surrogate-Key"] end should "return 304 when If-Modified-Since header is satisfied" do get_show(@rubygem) + assert_response :success set_cache_header get_show(@rubygem) + assert_response :not_modified end should "return 200 when If-Modified-Since header is not satisfied" do get_show(@rubygem) + assert_response :success set_cache_header @rubygem.update(updated_at: Time.zone.now + 1) get_show(@rubygem) + assert_response :success end should "return 404 if all versions yanked" do get_show(@rubygem) + assert_response :success set_cache_header @@ -109,6 +120,7 @@ def self.should_respond_to(format) end get_show(@rubygem) + assert_response :not_found end end @@ -146,6 +158,7 @@ def self.should_respond_to(format) set_cache_header get_show(@rubygem) + assert_response :not_modified end end @@ -160,6 +173,7 @@ def self.should_respond_to(format) should "give all releases" do get_show(@rubygem) + assert_equal 12, JSON.load(@response.body).size end end @@ -174,6 +188,7 @@ def self.should_respond_to(format) should "return latest version" do get_latest @rubygem + assert_equal "3.0.0", JSON.load(@response.body)["version"] end end @@ -188,6 +203,7 @@ def self.should_respond_to(format) should "return latest version" do get :latest, params: { id: @rubygem.name, format: "js", callback: "blah" } + assert_match(/blah\(.*\)\Z/, @response.body) end end @@ -202,6 +218,7 @@ def self.should_respond_to(format) should "return latest version" do get :latest, params: { id: "blah", format: "json" } + assert_equal "unknown", JSON.load(@response.body)["version"] end end @@ -214,6 +231,7 @@ def self.should_respond_to(format) should "return latest version" do get :latest, params: { id: @rubygem.name, format: "json" } + assert_equal "unknown", JSON.load(@response.body)["version"] end end @@ -227,6 +245,7 @@ def self.should_respond_to(format) should "return most recent version" do get :latest, params: { id: @rubygem.name, format: "json" } + assert_equal "2.0.0", JSON.load(@response.body)["version"] end end @@ -239,6 +258,7 @@ def self.should_respond_to(format) should "return license info" do get :show, params: { id: @rubygem.name, format: "json" } + assert_equal "MIT", JSON.load(@response.body).first["licenses"] end end @@ -287,11 +307,11 @@ def self.should_respond_to(format) assert_equal 3, ret_versions.size - assert ret_versions.include?(@version_one_latest.full_name) - assert ret_versions.include?(@version_two_earlier.full_name) - assert ret_versions.include?(@version_three.full_name) - refute ret_versions.include?(@version_one_earlier.full_name) - refute ret_versions.include?(@version_two_latest.full_name) + assert_includes ret_versions, @version_one_latest.full_name + assert_includes ret_versions, @version_two_earlier.full_name + assert_includes ret_versions, @version_three.full_name + refute_includes ret_versions, @version_one_earlier.full_name + refute_includes ret_versions, @version_two_latest.full_name end end end diff --git a/test/functional/api/v1/web_hooks_controller_test.rb b/test/functional/api/v1/web_hooks_controller_test.rb index ece731c0334..306aed704b3 100644 --- a/test/functional/api/v1/web_hooks_controller_test.rb +++ b/test/functional/api/v1/web_hooks_controller_test.rb @@ -3,34 +3,43 @@ class Api::V1::WebHooksControllerTest < ActionController::TestCase def self.should_not_find_it should respond_with :not_found + should "say gem is not found" do assert page.has_content?("could not be found") end end + setup do + NotifyWebHookJob.any_instance.stubs(:sleep) + end + context "with incorrect api key" do context "no api key" do should "forbid access when creating a web hook" do rubygem = create(:rubygem) post :create, params: { gem_name: rubygem.name, url: "http://example.com" } - assert @response.body.include? "Access Denied" - assert WebHook.count.zero? + + assert_includes @response.body, "Access Denied" + assert_predicate WebHook.count, :zero? end should "forbid access when listing hooks" do get :index - assert @response.body.include? "Access Denied" + + assert_includes @response.body, "Access Denied" end should "forbid access when firing hooks" do post :fire, params: { gem_name: WebHook::GLOBAL_PATTERN, url: "http://example.com" } - assert @response.body.include? "Access Denied" + + assert_includes @response.body, "Access Denied" end should "forbid access when removing a web hook" do hook = create(:web_hook) delete :remove, params: { gem_name: hook.rubygem.name, url: hook.url } - assert @response.body.include? "Access Denied" + + assert_includes @response.body, "Access Denied" assert_equal 1, WebHook.count end end @@ -55,6 +64,7 @@ def self.should_respond_to(format) should respond_with :success should "be able to parse body" do payload = yield(@response.body) + assert_equal @global_hook.payload, payload["all gems"].first assert_equal @rubygem_hook.payload, payload[@rubygem.name].first end @@ -64,7 +74,7 @@ def self.should_respond_to(format) context "with webhook actions api key scope" do setup do @url = "http://example.org" - @user = create(:api_key, key: "12342", access_webhooks: true).user + @user = create(:api_key, key: "12342", scopes: %i[access_webhooks]).user @request.env["HTTP_AUTHORIZATION"] = "12342" end @@ -77,29 +87,52 @@ def self.should_respond_to(format) context "On POST to fire for all gems" do setup do - RestClient.stubs(:post) + stub_request(:post, "https://api.hookrelay.dev/hooks///webhook_id-fire") + .with(headers: { + "Content-Type" => "application/json", + "HR_TARGET_URL" => @url, + "HR_MAX_ATTEMPTS" => "1" + }).to_return(status: 200, body: '{"id":"delivery-id"}', headers: { "Content-Type" => "application/json" }) + + stub_request(:get, "https://app.hookrelay.dev/api/v1/accounts//hooks//deliveries/delivery-id") + .to_return(status: 200, body: { "status" => "success", "responses" => [ + { "code" => 200, "body" => "OK", "headers" => { "Content-Type" => "text/plain" } } + ] }.to_json, headers: { "Content-Type" => "application/json" }) + post :fire, params: { gem_name: WebHook::GLOBAL_PATTERN, url: @url } end should respond_with :success should "say successfully deployed" do content = "Successfully deployed webhook for #{@gemcutter.name} to #{@url}" + assert page.has_content?(content) - assert WebHook.count.zero? + assert_predicate WebHook.count, :zero? end end context "On POST to fire for all gems that fails" do setup do - RestClient.stubs(:post).raises(RestClient::Exception.new) + stub_request(:post, "https://api.hookrelay.dev/hooks///webhook_id-fire") + .with(headers: { + "Content-Type" => "application/json", + "HR_TARGET_URL" => @url, + "HR_MAX_ATTEMPTS" => "1" + }).to_return(status: 200, body: '{"id":"delivery-id"}', headers: { "Content-Type" => "application/json" }) + + stub_request(:get, "https://app.hookrelay.dev/api/v1/accounts//hooks//deliveries/delivery-id") + .to_return(status: 200, body: { "status" => "failure", +"failure_reason" => "timed out" }.to_json, headers: { "Content-Type" => "application/json" }) + post :fire, params: { gem_name: WebHook::GLOBAL_PATTERN, url: @url } end should respond_with :bad_request should "say there was a problem" do content = "There was a problem deploying webhook for #{@gemcutter.name} to #{@url}" + assert page.has_content?(content) - assert WebHook.count.zero? + assert_predicate WebHook.count, :zero? end end @@ -110,6 +143,7 @@ def self.should_respond_to(format) should respond_with :bad_request should "say url was not provided" do content = "URL was not provided" + assert page.has_content?(content) end end @@ -123,28 +157,50 @@ def self.should_respond_to(format) context "On POST to fire for a specific gem" do setup do - RestClient.stubs(:post) + stub_request(:post, "https://api.hookrelay.dev/hooks///webhook_id-fire") + .with(headers: { + "Content-Type" => "application/json", + "HR_TARGET_URL" => @url, + "HR_MAX_ATTEMPTS" => "1" + }).to_return(status: 200, body: '{"id":"delivery-id"}', headers: { "Content-Type" => "application/json" }) + + stub_request(:get, "https://app.hookrelay.dev/api/v1/accounts//hooks//deliveries/delivery-id") + .to_return(status: 200, body: { "status" => "success", "responses" => [ + { "code" => 200, "body" => "OK", "headers" => { "Content-Type" => "text/plain" } } + ] }.to_json, headers: { "Content-Type" => "application/json" }) + post :fire, params: { gem_name: @rubygem.name, url: @url } end should respond_with :success should "say successfully deployed" do assert page.has_content?("Successfully deployed webhook for #{@rubygem.name} to #{@url}") - assert WebHook.count.zero? + assert_predicate WebHook.count, :zero? end end context "On POST to fire for a specific gem that fails" do setup do - RestClient.stubs(:post).raises(RestClient::Exception.new) + stub_request(:post, "https://api.hookrelay.dev/hooks///webhook_id-fire") + .with(headers: { + "Content-Type" => "application/json", + "HR_TARGET_URL" => @url, + "HR_MAX_ATTEMPTS" => "1" + }).to_return(status: 200, body: '{"id":"delivery-id"}', headers: { "Content-Type" => "application/json" }) + + stub_request(:get, "https://app.hookrelay.dev/api/v1/accounts//hooks//deliveries/delivery-id") + .to_return(status: 200, body: { "status" => "failure", +"failure_reason" => "exceeded", "responses" => [{ "code" => 404 }] }.to_json, headers: { "Content-Type" => "application/json" }) + post :fire, params: { gem_name: @rubygem.name, url: @url } end should respond_with :bad_request should "say there was a problem" do content = "There was a problem deploying webhook for #{@rubygem.name} to #{@url}" + assert page.has_content?(content) - assert WebHook.count.zero? + assert_predicate WebHook.count, :zero? end end @@ -174,6 +230,7 @@ def self.should_respond_to(format) should respond_with :success should "say webhook was removed" do content = "Successfully removed webhook for #{@rubygem.name} to #{@rubygem_hook.url}" + assert page.has_content?(content) end should "have actually removed the webhook" do @@ -192,6 +249,7 @@ def self.should_respond_to(format) should respond_with :success should "say webhook was removed" do content = "Successfully removed webhook for all gems to #{@global_hook.url}" + assert page.has_content?(content) end should "have actually removed the webhook" do @@ -215,6 +273,7 @@ def self.should_respond_to(format) end should respond_with :not_found + should "say webhook was not found" do assert page.has_content?("No such webhook exists under your account.") end @@ -230,6 +289,7 @@ def self.should_respond_to(format) end should respond_with :not_found + should "say webhook was not found" do assert page.has_content?("No such webhook exists under your account.") end @@ -245,6 +305,7 @@ def self.should_respond_to(format) end should respond_with :created + should "say webhook was created" do assert page.has_content?("Successfully created webhook for #{@rubygem.name} to #{@url}") end @@ -306,6 +367,7 @@ def self.should_respond_to(format) end should respond_with :conflict + should "be only 1 web hook" do assert_equal 1, WebHook.count end diff --git a/test/functional/api/v1/webauthn_verifications_controller_test.rb b/test/functional/api/v1/webauthn_verifications_controller_test.rb new file mode 100644 index 00000000000..84827c68152 --- /dev/null +++ b/test/functional/api/v1/webauthn_verifications_controller_test.rb @@ -0,0 +1,230 @@ +require "test_helper" + +# Not to be confused with WebauthnVerificationsControllerTest. This is for the API. + +class Api::V1::WebauthnVerificationsControllerTest < ActionController::TestCase + should "route new paths to new controller" do + route = { controller: "api/v1/webauthn_verifications", action: "create" } + + assert_recognizes(route, { path: "/api/v1/webauthn_verification", method: :post }) + end + + def authorize_with(str) + @request.env["HTTP_AUTHORIZATION"] = "Basic #{Base64.encode64(str)}" + end + + def self.should_respond_to_format(format) + context "when the request asks for format '#{format}'" do + setup do + @user = create(:user) + create(:webauthn_credential, user: @user) + authorize_with("#{@user.email}:#{@user.password}") + + travel_to Time.utc(2023, 1, 1, 0, 0, 0) do + post :create, format: format + end + + @token = @user.webauthn_verification.path_token + end + + should respond_with :success + + should "have a body" do + assert_not_nil @response.body + end + + if format == :plain + should "return only the Webauthn verification URL with path token" do + assert_equal @response.body, "http://test.host/webauthn_verification/#{@token}" + end + else + should "return a YAML or JSON document with path token" do + response = YAML.safe_load(@response.body) + + assert_equal response["path"], "http://test.host/webauthn_verification/#{@token}" + end + + should "return a YAML or JSON document with path expiry" do + response = YAML.safe_load(@response.body) + + assert_equal "2023-01-01T00:02:00.000Z", response["expiry"] + end + end + end + end + + context "on POST to create" do + context "with no credentials" do + setup { post :create } + should "deny access" do + assert_response :unauthorized + assert_match "HTTP Basic: Access denied.", @response.body + end + end + + context "with invalid credentials" do + setup do + @user = create(:user) + create(:webauthn_credential, user: @user) + authorize_with("bad\0:creds") + post :create + end + + should "deny access" do + assert_response :unauthorized + assert_match "HTTP Basic: Access denied.", @response.body + end + end + + context "when authenticating with an api key" do + setup do + @api_key = create(:api_key, key: "12345", scopes: %i[push_rubygem]) + @user = @api_key.user + create(:webauthn_credential, user: @user) + @request.env["HTTP_AUTHORIZATION"] = "12345" + post :create + @token = @user.webauthn_verification.path_token + end + + should respond_with :success + end + + context "user has enabled webauthn" do + should_respond_to_format :yaml + should_respond_to_format :json + should_respond_to_format :plain + + should "not sign in user" do + refute_predicate @controller.request.env[:clearance], :signed_in? + end + end + + context "user has not enabled webauthn" do + setup do + @user = create(:user) + authorize_with("#{@user.email}:#{@user.password}") + post :create + end + + should respond_with :unprocessable_content + + should "tell the user they don't have a WebAuthn hardware token" do + assert_match "You don't have any security devices", response.body + end + end + end + + context "on GET to status" do + setup do + @user = create(:user) + create(:webauthn_credential, user: @user) + create(:webauthn_verification, user: @user) + end + + context "with valid credentials" do + setup do + authorize_with("#{@user.email}:#{@user.password}") + get :status, params: { webauthn_token: @user.webauthn_verification.path_token, format: :json } + end + + should respond_with :success + + should "return otp" do + json_response = JSON.parse(@response.body) + + assert_equal @user.webauthn_verification.otp, json_response["code"] + assert_equal "success", json_response["status"] + end + end + + context "with no credentials" do + setup do + get :status, params: { webauthn_token: @user.webauthn_verification.path_token, format: :json } + end + + should "deny access" do + assert_response :unauthorized + assert_match "HTTP Basic: Access denied.", @response.body + end + end + + context "with invalid credentials" do + setup do + authorize_with("bad\0:creds") + get :status, params: { webauthn_token: @user.webauthn_verification.path_token, format: :json } + end + + should "deny access" do + assert_response :unauthorized + assert_match "HTTP Basic: Access denied.", @response.body + end + end + + context "when authenticating with an api key" do + setup do + create(:api_key, key: "12345", scopes: %i[push_rubygem], owner: @user) + @request.env["HTTP_AUTHORIZATION"] = "12345" + get :status, params: { webauthn_token: @user.webauthn_verification.path_token, format: :json } + end + + should respond_with :success + + should "return otp" do + json_response = JSON.parse(@response.body) + + assert_equal @user.webauthn_verification.otp, json_response["code"] + assert_equal "success", json_response["status"] + end + end + + context "when webauthn otp is expired" do + setup do + @user.webauthn_verification.update!(otp_expires_at: 1.second.ago) + authorize_with("#{@user.email}:#{@user.password}") + get :status, params: { webauthn_token: @user.webauthn_verification.path_token, format: :json } + end + + should respond_with :success + + should "return expired" do + json_response = JSON.parse(@response.body) + + assert_equal "expired", json_response["status"] + assert_equal "The token in the link you used has either expired or been used already.", json_response["message"] + end + end + + context "with invalid webauthn token" do + setup do + authorize_with("#{@user.email}:#{@user.password}") + get :status, params: { webauthn_token: "11111", format: :json } + end + + should respond_with :success + + should "return not found" do + json_response = JSON.parse(@response.body) + + assert_equal "not_found", json_response["status"] + assert_equal "Not Found", json_response["message"] + end + end + + context "when otp has not been generated yet" do + setup do + authorize_with("#{@user.email}:#{@user.password}") + @user.webauthn_verification.update_column(:otp, nil) + get :status, params: { webauthn_token: @user.webauthn_verification.path_token, format: :json } + end + + should respond_with :success + + should "return pending" do + json_response = JSON.parse(@response.body) + + assert_equal "pending", json_response["status"] + assert_equal "Security device authentication is still pending.", json_response["message"] + end + end + end +end diff --git a/test/functional/api/v2/contents_controller_test.rb b/test/functional/api/v2/contents_controller_test.rb new file mode 100644 index 00000000000..06a1c5f94c5 --- /dev/null +++ b/test/functional/api/v2/contents_controller_test.rb @@ -0,0 +1,239 @@ +require "test_helper" + +class Api::V2::ContentsControllerTest < ActionController::TestCase + def get_index(rubygem, version_number, platform = nil, format: :sha256) + get :index, params: { rubygem_name: rubygem.name, version_number:, platform:, format: }.compact + end + + def set_cache_header + @request.if_modified_since = @response.headers["Last-Modified"] + @request.if_none_match = @response.etag + end + + context "routing to index" do + should "route to index" do + expected = { controller: "api/v2/contents", action: "index", rubygem_name: "foo", version_number: "1.0.0" } + + assert_recognizes expected, "/api/v2/rubygems/foo/versions/1.0.0/contents" + end + + should "route to index with .json" do + expected = { controller: "api/v2/contents", action: "index", rubygem_name: "foo", version_number: "1.0.0", format: "json" } + + assert_recognizes expected, "/api/v2/rubygems/foo/versions/1.0.0/contents.json" + end + + should "route to index with .yaml" do + expected = { controller: "api/v2/contents", action: "index", rubygem_name: "foo", version_number: "1.0.0", format: "yaml" } + + assert_recognizes expected, "/api/v2/rubygems/foo/versions/1.0.0/contents.yaml" + end + + should "route to index with .sha256" do + expected = { controller: "api/v2/contents", action: "index", rubygem_name: "foo", version_number: "1.0.0", format: "sha256" } + + assert_recognizes expected, "/api/v2/rubygems/foo/versions/1.0.0/contents.sha256" + end + + should "not be confused by prerelease versions" do + expected = { controller: "api/v2/contents", action: "index", rubygem_name: "foo", version_number: "1.0.0-a.pre" } + + assert_recognizes expected, "/api/v2/rubygems/foo/versions/1.0.0-a.pre/contents" + end + + should "not route when disallowed characters are used" do + assert_raises(ActionController::UrlGenerationError) do + get :index, params: { rumgem_name: "foo", version_number: "bad%20version", format: "json" } + end + end + end + + context "on GET to index" do + setup do + @rubygem = create(:rubygem) + @jruby_version = create(:version, rubygem: @rubygem, number: "2.0.0", platform: "jruby") + @version = create(:version, rubygem: @rubygem, number: "2.0.0") + create(:version, rubygem: @rubygem, number: "1.0.0.pre", prerelease: true) + create(:version, rubygem: @rubygem, number: "3.0.0", indexed: false) + + @rubygem2 = create(:rubygem) + create(:version, rubygem: @rubygem2, number: "3.0.0") + create(:version, rubygem: @rubygem2, number: "2.0.0") + create(:version, rubygem: @rubygem2, number: "1.0.0") + + @checksums = { "file.rb" => "abc12345", "file2.rb" => "def67890" } + @version.manifest.store_checksums(@checksums) + + @jruby_checksums = { "file.rb" => "c0ffee11", "file2.rb" => "c0ffee22" } + @jruby_version.manifest.store_checksums(@jruby_checksums) + end + + context "with .json format" do + should "return checksums for the gem" do + get_index(@rubygem, "2.0.0", format: :json) + + assert_response :success + + data = JSON.parse(@response.body) + expected_data = @checksums.transform_values { |checksum| { "sha256" => checksum } } + + assert_equal expected_data, data + end + end + + context "with .yaml format" do + should "return checksums for the gem" do + get_index(@rubygem, "2.0.0", format: :yaml) + + assert_response :success + + data = YAML.safe_load(@response.body) + expected_data = @checksums.transform_values { |checksum| { "sha256" => checksum } } + + assert_equal expected_data, data + end + end + + context "with .sha256 format" do + should "return not found when the hashed files are not available" do + get_index(@rubygem, "1.0.0.pre") + + assert_response :not_found + assert_equal "Content is unavailable for this version.", @response.body + end + + should "return not found when the gem is not indexed" do + get_index(@rubygem, "3.0.0") + + assert_response :not_found + assert_equal "This version could not be found.", @response.body + end + + should "return a list of contents for the first gem" do + get_index(@rubygem, "2.0.0") + + assert_response :success + assert_equal <<~CHECKSUMS, @response.body + abc12345 file.rb + def67890 file2.rb + CHECKSUMS + end + + should "not be confused by ruby platform" do + get :index, params: { rubygem_name: @rubygem.name, version_number: "2.0.0", platform: "ruby", format: :sha256 } + + assert_response :success + assert_equal <<~CHECKSUMS, @response.body + abc12345 file.rb + def67890 file2.rb + CHECKSUMS + end + + should "return jruby version with platform param" do + get_index @rubygem, "2.0.0", "jruby" + + assert_response :success + assert_equal <<~CHECKSUMS, @response.body + c0ffee11 file.rb + c0ffee22 file2.rb + CHECKSUMS + end + end + + should "return a platformed gem even without platform param if it was created more recently" do + darwin_version = create(:version, rubygem: @rubygem, number: "2.0.0", platform: "universal-darwin-20") + darwin_checksums = { "file.rb" => "file.rb-darwin", "file2.rb" => "file2.rb-darwin" } + darwin_version.manifest.store_checksums(darwin_checksums) + + get_index @rubygem, "2.0.0", format: :json + + assert_response :success + data = JSON.parse(@response.body) + expected_data = darwin_checksums.transform_values { |checksum| { "sha256" => checksum } } + + assert_equal expected_data, data + end + + should "return Last-Modified header" do + get_index(@rubygem, "2.0.0") + + assert_equal @response.headers["Last-Modified"], @rubygem.updated_at.httpdate + end + + should "return 304 when If-Modified-Since header is satisfied" do + get_index(@rubygem, "2.0.0") + + assert_response :success + set_cache_header + + get_index(@rubygem, "2.0.0") + + assert_response :not_modified + end + + should "return 200 when If-Modified-Since header is not satisfied" do + get_index(@rubygem, "2.0.0") + + assert_response :success + set_cache_header + + @rubygem.update(updated_at: Time.zone.now + 1) + get_index(@rubygem, "2.0.0") + + assert_response :success + end + + should "return 404 if all versions yanked" do + get_index(@rubygem, "2.0.0") + + assert_response :success + set_cache_header + + travel_to(Time.zone.now + 1) do + @rubygem.public_versions.each { |v| v.update!(indexed: false) } + end + + get_index(@rubygem, "2.0.0") + + assert_response :not_found + end + end + + context "on GET to index for an unknown gem" do + setup do + get_index(Rubygem.new(name: "nonexistent_gem"), "1.2.3") + end + + should "return a 404" do + assert_response :not_found + end + + should "say gem could not be found" do + assert_equal "This gem could not be found", @response.body + end + end + + context "on GET to index for a yanked gem" do + setup do + @rubygem = create(:rubygem) + create(:version, rubygem: @rubygem, indexed: false, number: "1.0.0") + get_index(@rubygem, "2.0.0") + end + + should "return a 404" do + assert_response :not_found + end + + should "say gem could not be found" do + assert_equal "This version could not be found.", @response.body + end + + should "should cache the 404" do + set_cache_header + + get_index(@rubygem, "2.0.0") + + assert_response :not_modified + end + end +end diff --git a/test/functional/api/v2/versions_controller_test.rb b/test/functional/api/v2/versions_controller_test.rb index 19f46211e08..1d717a85535 100644 --- a/test/functional/api/v2/versions_controller_test.rb +++ b/test/functional/api/v2/versions_controller_test.rb @@ -14,12 +14,45 @@ def self.should_respond_to(format) context "with #{format.to_s.upcase}" do should "have a list of versions for the first gem" do get_show(@rubygem, "2.0.0", format) - @response.body + yield @response.body + assert_response :success end end end + context "routing to show" do + should "route to show" do + expected = { controller: "api/v2/versions", action: "show", rubygem_name: "foo", number: "1.0.0" } + + assert_recognizes expected, "/api/v2/rubygems/foo/versions/1.0.0" + end + + should "route to show with .json" do + expected = { controller: "api/v2/versions", action: "show", rubygem_name: "foo", number: "1.0.0", format: "json" } + + assert_recognizes expected, "/api/v2/rubygems/foo/versions/1.0.0.json" + end + + should "route to show with .yaml" do + expected = { controller: "api/v2/versions", action: "show", rubygem_name: "foo", number: "1.0.0", format: "yaml" } + + assert_recognizes expected, "/api/v2/rubygems/foo/versions/1.0.0.yaml" + end + + should "not be confused by prerelease versions" do + expected = { controller: "api/v2/versions", action: "show", rubygem_name: "foo", number: "1.0.0-a.pre" } + + assert_recognizes expected, "/api/v2/rubygems/foo/versions/1.0.0-a.pre" + end + + should "not route when disallowed characters are used" do + assert_raises(ActionController::UrlGenerationError) do + get :show, params: { rumgem_name: "foo", number: "bad%20version", format: "json" } + end + end + end + context "on GET to show" do setup do @rubygem = create(:rubygem) @@ -34,7 +67,7 @@ def self.should_respond_to(format) end should_respond_to(:json) do |body| - JSON.load(body) + JSON.parse(body) end should_respond_to(:yaml) do |body| @@ -43,30 +76,36 @@ def self.should_respond_to(format) should "return Last-Modified header" do get_show(@rubygem, "2.0.0") + assert_equal @response.headers["Last-Modified"], @rubygem.updated_at.httpdate end should "return 304 when If-Modified-Since header is satisfied" do get_show(@rubygem, "2.0.0") + assert_response :success set_cache_header get_show(@rubygem, "2.0.0") + assert_response :not_modified end should "return 200 when If-Modified-Since header is not satisfied" do get_show(@rubygem, "2.0.0") + assert_response :success set_cache_header @rubygem.update(updated_at: Time.zone.now + 1) get_show(@rubygem, "2.0.0") + assert_response :success end should "return 404 if all versions yanked" do get_show(@rubygem, "2.0.0") + assert_response :success set_cache_header @@ -75,6 +114,7 @@ def self.should_respond_to(format) end get_show(@rubygem, "2.0.0") + assert_response :not_found end @@ -85,16 +125,22 @@ def self.should_respond_to(format) should "return version by position without platform param" do get_show(@rubygem, "2.0.0") + assert_response :success response = JSON.load(@response.body) + assert_equal "jruby", response["platform"] + assert_equal "2.0.0", response["version"] end should "return platform version with platform param" do get :show, params: { rubygem_name: @rubygem.name, number: "2.0.0", platform: "ruby", format: "json" } + assert_response :success response = JSON.load(@response.body) + assert_equal "ruby", response["platform"] + assert_equal "2.0.0", response["version"] end end end @@ -132,6 +178,7 @@ def self.should_respond_to(format) set_cache_header get_show(@rubygem, "2.0.0") + assert_response :not_modified end end @@ -146,6 +193,7 @@ def self.should_respond_to(format) should "gives one specific version" do get_show(@rubygem, "4.0.0") + assert_kind_of Hash, JSON.load(@response.body) assert_equal "4.0.0", JSON.load(@response.body)["number"] end @@ -165,5 +213,18 @@ def self.should_respond_to(format) should("have dependencies") { assert @response["dependencies"] } should("have development dependencies") { assert @response["dependencies"]["development"] } should("have runtime dependencies") { assert @response["dependencies"]["runtime"] } + should "have expected keys" do + assert_equal( + %w[ + name downloads version version_created_at version_downloads platform + authors info licenses metadata yanked sha spec_sha project_uri gem_uri + homepage_uri wiki_uri documentation_uri mailing_list_uri + source_code_uri bug_tracker_uri changelog_uri funding_uri dependencies + built_at created_at description downloads_count number summary + rubygems_version ruby_version prerelease requirements + ], + @response.keys + ) + end end end diff --git a/test/functional/api_keys_controller_test.rb b/test/functional/api_keys_controller_test.rb index 6bf38e51bd8..087bed7af79 100644 --- a/test/functional/api_keys_controller_test.rb +++ b/test/functional/api_keys_controller_test.rb @@ -1,6 +1,8 @@ require "test_helper" class ApiKeysControllerTest < ActionController::TestCase + include ActiveJob::TestHelper + context "when not logged in" do context "on GET to index" do setup { get :index } @@ -33,12 +35,12 @@ class ApiKeysControllerTest < ActionController::TestCase end context "on DELETE to destroy" do - setup { post :create, params: { id: 1 } } + setup { delete :destroy, params: { id: 1 } } should redirect_to("the sign in page") { sign_in_path } end - context "on DELETE to destroy" do + context "on DELETE to reset" do setup { delete :reset } should redirect_to("the sign in page") { sign_in_path } @@ -50,10 +52,12 @@ class ApiKeysControllerTest < ActionController::TestCase @user = create(:user) sign_in_as(@user) session[:verification] = 10.minutes.from_now + session[:verified_user] = @user.id end teardown do session[:verification] = nil + session[:verified_user] = nil end context "on GET to index" do @@ -67,7 +71,7 @@ class ApiKeysControllerTest < ActionController::TestCase context "api key exists" do setup do - @api_key = create(:api_key, user: @user) + @api_key = create(:api_key, owner: @user) get :index end @@ -92,8 +96,9 @@ class ApiKeysControllerTest < ActionController::TestCase context "on POST to create" do context "with successful save" do setup do - post :create, params: { api_key: { name: "test", add_owner: true } } - Delayed::Worker.new.work_off + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + post :create, params: { api_key: { name: "test", add_owner: true } } + end end should redirect_to("the key index page") { profile_api_keys_path } @@ -102,11 +107,12 @@ class ApiKeysControllerTest < ActionController::TestCase assert_equal "test", api_key.name assert @controller.session[:api_key] - assert api_key.can_add_owner? + assert_predicate api_key, :can_add_owner? end should "deliver api key created email" do - refute ActionMailer::Base.deliveries.empty? + refute_empty ActionMailer::Base.deliveries email = ActionMailer::Base.deliveries.last + assert_equal [@user.email], email.to assert_equal ["no-reply@mailer.rubygems.org"], email.from assert_equal "New API key created for rubygems.org", email.subject @@ -121,14 +127,61 @@ class ApiKeysControllerTest < ActionController::TestCase end should "not create new key for user" do - assert @user.reload.api_keys.empty? + assert_empty @user.reload.api_keys + end + end + + context "with a gem scope" do + setup do + @ownership = create(:ownership, user: @user, rubygem: create(:rubygem)) + end + + should "have a gem scope with valid id" do + post :create, params: { api_key: { name: "gem scope", add_owner: true, rubygem_id: @ownership.rubygem.id } } + + created_key = @user.reload.api_keys.find_by(name: "gem scope") + + assert_equal @ownership.rubygem, created_key.rubygem + end + + should "display error with invalid id" do + post :create, params: { api_key: { name: "gem scope", add_owner: true, rubygem_id: -1 } } + + assert_equal "Rubygem must be a gem that you are an owner of", flash[:error] + assert_empty @user.reload.api_keys + end + + should "displays error with gem scope without applicable scope enabled" do + post :create, params: { api_key: { name: "gem scope", index_rubygems: true, rubygem_id: @ownership.rubygem.id } } + + assert_equal "Rubygem scope can only be set for push/yank rubygem, and add/remove owner scopes", flash[:error] + assert_empty @user.reload.api_keys + end + end + + context "with an expiration" do + should "create a key" do + expires_at = 1.month.from_now + post :create, params: { api_key: { name: "expiration", add_owner: true, expires_at: } } + + created_key = @user.reload.api_keys.sole + + assert_equal expires_at.change(usec: 0), created_key.expires_at + end + + should "display error with invalid expiration" do + expires_at = 1.month.ago + post :create, params: { api_key: { name: "expiration", add_owner: true, expires_at: } } + + assert_includes flash[:error], "Expires at must be in the future" + assert_empty @user.reload.api_keys end end end context "on GET to edit" do setup do - @api_key = create(:api_key, user: @user) + @api_key = create(:api_key, owner: @user) get :edit, params: { id: @api_key.id } end @@ -138,10 +191,18 @@ class ApiKeysControllerTest < ActionController::TestCase assert page.has_content? "Edit API key" assert_select "form > input.form__input", value: "ci-key" end + + should "redirect to index with soft deleted key" do + @api_key.soft_delete! + get :edit, params: { id: @api_key.id } + + assert_redirected_to profile_api_keys_path + assert_equal "An invalid API key cannot be edited. Please delete it and create a new one.", flash[:error] + end end context "on PATCH to update" do - setup { @api_key = create(:api_key, user: @user) } + setup { @api_key = create(:api_key, owner: @user) } context "with successful save" do setup do @@ -150,48 +211,110 @@ class ApiKeysControllerTest < ActionController::TestCase end should redirect_to("the key index page") { profile_api_keys_path } + should "update test key scope" do - assert @api_key.can_add_owner? + assert_predicate @api_key, :can_add_owner? end end context "with unsuccessful save" do setup do - patch :update, params: { api_key: { name: "", add_owner: true }, id: @api_key.id } + patch :update, params: { api_key: { name: "", add_owner: true, show_dashboard: true }, id: @api_key.id } end should "show error to user" do - assert page.has_content? "Name can't be blank" + assert_text "Show dashboard scope must be enabled exclusively" end should "not update scope of test key" do - refute @api_key.can_add_owner? + refute_predicate @api_key, :can_add_owner? + end + end + + context "gem scope" do + setup do + @ownership = create(:ownership, user: @user, rubygem: create(:rubygem)) + @api_key.update(rubygem_id: @ownership.rubygem.id, scopes: %i[push_rubygem]) + end + + should "to all gems" do + patch :update, params: { api_key: { rubygem_id: nil }, id: @api_key.id } + + assert_nil @api_key.reload.rubygem + end + + should "to another gem" do + another_ownership = create(:ownership, user: @user, rubygem: create(:rubygem)) + patch :update, params: { api_key: { rubygem_id: another_ownership.rubygem.id }, id: @api_key.id } + + assert_equal another_ownership.rubygem, @api_key.reload.rubygem + end + + should "displays error with invalid id" do + assert_no_changes @api_key do + patch :update, params: { api_key: { rubygem_id: -1 }, id: @api_key.id } + + assert_equal "Rubygem must be a gem that you are an owner of", flash[:error] + end + end + + should "displays error with gem scope without applicable scope enabled" do + assert_no_changes @api_key do + patch :update, params: { api_key: { push_rubygem: false }, id: @api_key.id } + end + assert_equal "Please enable at least one scope and Rubygem scope can only be set for push/yank rubygem, and add/remove owner scopes", + flash[:error] + end + end + + context "with an expiration" do + should "not allow chaging expiration" do + @api_key.update_column(:expires_at, 1.month.from_now) + expires_at = 1.year.from_now + + assert_no_changes -> { @api_key.reload.expires_at } do + patch :update, params: { api_key: { expires_at: }, id: @api_key.id } + + assert_response :bad_request + end + end + + should "not allow adding expiration" do + expires_at = 1.year.from_now + assert_no_changes -> { @api_key.reload.expires_at } do + patch :update, params: { api_key: { expires_at: }, id: @api_key.id } + + assert_response :bad_request + end end end end context "on DELETE to destroy" do context "user is owner of key" do - setup { @api_key = create(:api_key, user: @user) } + setup { @api_key = create(:api_key, owner: @user) } context "with successful destroy" do setup { delete :destroy, params: { id: @api_key.id } } should redirect_to("the index api key page") { profile_api_keys_path } - should "delete api key of user" do - assert @user.api_keys.empty? + + should "expire api key of user" do + assert_empty @user.api_keys.unexpired + refute_empty @user.api_keys end end context "with unsuccessful destroy" do setup do - ApiKey.any_instance.stubs(:destroy).returns(false) + ApiKey.any_instance.stubs(:expire!).returns(false) delete :destroy, params: { id: @api_key.id } end should redirect_to("the index api key page") { profile_api_keys_path } - should "not delete api key of user" do - refute @user.api_keys.empty? + + should "not expire api key of user" do + refute_empty @user.api_keys.unexpired end end end @@ -203,23 +326,151 @@ class ApiKeysControllerTest < ActionController::TestCase end should respond_with :not_found - should "not delete the api key" do - assert ApiKey.find(@api_key.id) + + should "not expire the api key" do + refute_predicate ApiKey.find(@api_key.id), :expired? end end end context "on DELETE to reset" do setup do - create(:api_key, key: "1234", user: @user) - create(:api_key, key: "2345", user: @user) + create(:api_key, key: "1234", owner: @user) + create(:api_key, key: "2345", owner: @user) delete :reset end should redirect_to("the index api key page") { profile_api_keys_path } - should "delete all api key of user" do - assert @user.api_keys.empty? + + should "expire all api key of user" do + @user.api_keys.each { assert_predicate _1, :expired? } + end + end + + context "when user owns a gem with more than MFA_REQUIRED_THRESHOLD downloads" do + setup do + @rubygem = create(:rubygem) + create(:ownership, rubygem: @rubygem, user: @user) + GemDownload.increment( + Rubygem::MFA_REQUIRED_THRESHOLD + 1, + rubygem_id: @rubygem.id + ) + end + + redirect_scenarios = { + "DELETE to reset" => { action: :reset, request: { method: "DELETE" }, path: "/profile/api_keys/reset" }, + "GET to index" => { action: :index, request: { method: "GET" }, path: "/profile/api_keys" }, + "GET to new" => { action: :new, request: { method: "GET" }, path: "/profile/api_keys/new" }, + "POST to create" => { action: :create, request: { method: "POST", params: { api_key: { name: "test", add_owner: true } } }, +path: "/profile/api_keys" }, + "GET to edit" => { action: :edit, request: { method: "GET", params: { id: 1 } }, path: "/profile/api_keys/1/edit" }, + "PATCH to update" => { action: :update, request: { method: "PATCH", params: { id: 1, api_key: { name: "test", add_owner: true } } }, +path: "/profile/api_keys/1" }, + "DELETE to destroy" => { action: :destroy, request: { method: "DELETE", params: { id: 1 } }, path: "/profile/api_keys/1" } + } + + context "user has mfa disabled" do + redirect_scenarios.each do |label, request_params| + context "on #{label}" do + setup { process(request_params[:action], **request_params[:request]) } + + should redirect_to("the edit settings page") { edit_settings_path } + + should "set mfa_redirect_uri" do + assert_equal request_params[:path], @controller.session[:mfa_redirect_uri] + end + end + end + end + + context "user has mfa set to weak level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + end + + redirect_scenarios.each do |label, request_params| + context "on #{label}" do + setup { process(request_params[:action], **request_params[:request]) } + + should redirect_to("the settings page") { edit_settings_path } + + should "set mfa_redirect_uri" do + assert_equal request_params[:path], @controller.session[:mfa_redirect_uri] + end + end + end + end + + context "user has MFA set to strong level, expect normal behaviour" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + end + + context "on DELETE to reset" do + setup do + create(:api_key, key: "1234", owner: @user) + delete :reset + end + + should redirect_to("the index api key page") { profile_api_keys_path } + end + + context "on GET to index" do + setup { get :index } + + should redirect_to("the new api key page") { new_profile_api_key_path } + end + + context "on GET to new" do + setup { get :new } + + should respond_with :success + + should "render new api key form" do + assert page.has_content? "New API key" + end + end + + context "on POST to create" do + setup do + post :create, params: { api_key: { name: "test", add_owner: true } } + end + + should redirect_to("the key index page") { profile_api_keys_path } + end + + context "on GET to edit" do + setup do + @api_key = create(:api_key, owner: @user) + get :edit, params: { id: @api_key.id } + end + + should respond_with :success + + should "render edit api key form" do + assert page.has_content? "Edit API key" + end + end + + context "on PATCH to update" do + setup do + @api_key = create(:api_key, owner: @user) + patch :update, params: { api_key: { name: "test", add_owner: true }, id: @api_key.id } + @api_key.reload + end + + should redirect_to("the index api key page") { profile_api_keys_path } + end + + context "on DELETE to destroy" do + setup do + @api_key = create(:api_key, owner: @user) + delete :destroy, params: { id: @api_key.id } + end + + should redirect_to("the index api key page") { profile_api_keys_path } + end end end end diff --git a/test/functional/concerns/webauthn_verifiable_test.rb b/test/functional/concerns/webauthn_verifiable_test.rb new file mode 100644 index 00000000000..79dcf0b4f0b --- /dev/null +++ b/test/functional/concerns/webauthn_verifiable_test.rb @@ -0,0 +1,161 @@ +require "test_helper" + +class TestWebauthnAuthenticationController < ApplicationController + include WebauthnVerifiable + + def prompt + @user = User.find(params[:user_id]) + setup_webauthn_authentication(form_url: test_webauthn_authenticate_path) + + render json: { webauthn_options: @webauthn_options.to_json, webauthn_verification_url: @webauthn_verification_url } + end + + def prompt_with_session_options + @user = User.find(params[:user_id]) + setup_webauthn_authentication( + form_url: test_webauthn_authenticate_path, + session_options: { "foo" => "bar", "baz" => "qux" } + ) + + render json: { webauthn_options: @webauthn_options.to_json, webauthn_verification_url: @webauthn_verification_url } + end + + def authenticate + @user = User.find(params[:user_id]) + return render plain: @webauthn_error, status: :unauthorized unless webauthn_credential_verified? + + render plain: "success" + end +end + +class WebauthnVerifiableTest < ActionController::TestCase + setup do + @controller = TestWebauthnAuthenticationController.new + @user = create(:user) + @webauthn_credential = create(:webauthn_credential, user: @user) + + Rails.application.routes.draw do + scope controller: "test_webauthn_authentication" do + get :prompt + get :prompt_with_session_options + post :authenticate, as: :test_webauthn_authenticate + end + end + end + + context "#prompt" do + setup do + get :prompt, params: { user_id: @user.id } + @json_response = JSON.parse(@response.body) + end + + should "set webauthn_verification_url" do + assert_equal test_webauthn_authenticate_path, @json_response["webauthn_verification_url"] + end + + should "set webauthn_options" do + refute_nil @json_response["webauthn_options"]["challenge"] + refute_nil @json_response["webauthn_options"]["allowCredentials"] + end + + should "set webauthn_challenge in session" do + refute_nil session[:webauthn_authentication]["challenge"] + end + end + + context "#prompt with session options" do + setup do + get :prompt_with_session_options, params: { user_id: @user.id } + end + + should "set session options in session" do + assert_equal "bar", session[:webauthn_authentication]["foo"] + assert_equal "qux", session[:webauthn_authentication]["baz"] + end + end + + context "#authenticate" do + setup do + get :prompt, params: { user_id: @user.id } + @challenge = session[:webauthn_authentication]["challenge"] + @origin = WebAuthn.configuration.origin + @rp_id = URI.parse(@origin).host + @client = WebAuthn::FakeClient.new(@origin, encoding: false) + WebauthnHelpers.create_credential( + webauthn_credential: @webauthn_credential, + client: @client + ) + end + + context "with valid credentials" do + setup do + post( + :authenticate, + params: { + credentials: + WebauthnHelpers.get_result( + client: @client, + challenge: @challenge + ), + user_id: @user.id + } + ) + end + + should "return success" do + assert_equal "success", @response.body + end + + should "clear webauthn_authentication in session" do + assert_nil session[:webauthn_authentication] + end + end + + context "with missing credential params" do + setup do + post :authenticate, params: { user_id: @user.id } + end + + should respond_with :unauthorized + + should "return credentials required" do + assert_equal "Credentials required", @response.body + end + + should "clear webauthn_authentication in session" do + assert_nil session[:webauthn_authentication] + end + end + + context "when a Webauthn error occurs" do + setup do + @wrong_challenge = SecureRandom.hex + post( + :authenticate, + params: { + credentials: + WebauthnHelpers.get_result( + client: @client, + challenge: @wrong_challenge + ), + user_id: @user.id + } + ) + end + + should respond_with :unauthorized + + should "return error" do + assert_equal "WebAuthn::ChallengeVerificationError", @response.body + end + + should "clear webauthn_authentication in session" do + assert_nil session[:webauthn_authentication] + end + end + end + + teardown do + Rails.application.reload_routes! + end +end diff --git a/test/functional/dashboards_controller_test.rb b/test/functional/dashboards_controller_test.rb index d857f02a7de..9be6a1b3b2f 100644 --- a/test/functional/dashboards_controller_test.rb +++ b/test/functional/dashboards_controller_test.rb @@ -4,7 +4,7 @@ class DashboardsControllerTest < ActionController::TestCase context "When not logged in" do context "with show dashboard api key scope" do setup do - api_key = create(:api_key, key: "12345", show_dashboard: true) + api_key = create(:api_key, key: "12345", scopes: %i[show_dashboard]) @subscribed_version = create(:version, created_at: 1.hour.ago) create(:subscription, rubygem: @subscribed_version.rubygem, user: api_key.user) @@ -39,7 +39,7 @@ class DashboardsControllerTest < ActionController::TestCase context "on GET to show" do setup do - 3.times { create(:rubygem) } + create_list(:rubygem, 3) @gems = (1..3).map do rubygem = create(:rubygem) create(:ownership, rubygem: rubygem, user: @user) @@ -53,8 +53,9 @@ class DashboardsControllerTest < ActionController::TestCase should "render links" do @gems.each do |g| assert page.has_content?(g.name) - selector = "a[href='#{rubygem_path(g)}'][title='#{g.versions.most_recent.info}']" - assert page.has_selector?(selector) + selector = "a[href='#{rubygem_path(g.slug)}'][title='#{g.most_recent_version.info}']" + + page.assert_selector(selector) end end end @@ -82,8 +83,8 @@ class DashboardsControllerTest < ActionController::TestCase should "render posts with platform-specific titles and links of all subscribed versions" do @subscribed_versions.each do |v| assert_select "entry > title", count: 1, text: v.to_title - assert_select "entry > link[href='#{rubygem_version_url(v.rubygem, v.slug)}']", count: 1 - assert_select "entry > id", count: 1, text: rubygem_version_url(v.rubygem, v.slug) + assert_select "entry > link[href='#{rubygem_version_url(v.rubygem.slug, v.slug)}']", count: 1 + assert_select "entry > id", count: 1, text: rubygem_version_url(v.rubygem.slug, v.slug) end end @@ -107,6 +108,51 @@ class DashboardsControllerTest < ActionController::TestCase end end end + + context "when user owns a gem with more than MFA_REQUIRED_THRESHOLD downloads" do + setup do + @rubygem = create(:rubygem) + create(:ownership, rubygem: @rubygem, user: @user) + GemDownload.increment( + Rubygem::MFA_REQUIRED_THRESHOLD + 1, + rubygem_id: @rubygem.id + ) + end + + context "user has mfa disabled" do + setup { get :show } + should redirect_to("the edit settings page") { edit_settings_path } + + should "set mfa_redirect_uri" do + assert_equal dashboard_path, session[:mfa_redirect_uri] + end + end + + context "user has mfa set to weak level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + get :show + end + + should redirect_to("the settings page") { edit_settings_path } + + should "set mfa_redirect_uri" do + assert_equal dashboard_path, session[:mfa_redirect_uri] + end + end + + context "user has MFA set to strong level, expect normal behaviour" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + get :show + end + + should "stay on dashboard page without redirecting" do + assert_response :success + assert page.has_content? "Dashboard" + end + end + end end context "On GET to show without being signed in" do diff --git a/test/functional/dependencies_controller_test.rb b/test/functional/dependencies_controller_test.rb index ce5cb3c7b42..16f5185fafc 100644 --- a/test/functional/dependencies_controller_test.rb +++ b/test/functional/dependencies_controller_test.rb @@ -8,7 +8,7 @@ class DependenciesControllerTest < ActionController::TestCase @dep_rubygem = create(:rubygem) ["1.0.2", "2.4.3", "4.5.6"].map do |ver_number| - create(:version, number: ver_number, rubygem: @dep_rubygem) + create(:version, number: ver_number, rubygem: @dep_rubygem, indexed: true) end create(:dependency, @@ -24,7 +24,11 @@ def request_endpoint(rubygem, version, format = "html") def render_str_call(scope, dependencies) local_var = { scope: scope, dependencies: dependencies, gem_name: @rubygem.name } - ActionController::Base.new.render_to_string(partial: "dependencies/dependencies", formats: [:html], locals: local_var) + DependenciesController.renderer.new( + { + "HTTP_HOST" => "test.host" + } + ).render(partial: "dependencies/dependencies", formats: [:html], locals: local_var) end context "GET to show in html" do @@ -33,6 +37,7 @@ def render_str_call(scope, dependencies) end should respond_with :success + should "render gem name" do assert page.has_content?(@rubygem.name) end @@ -51,6 +56,7 @@ def render_str_call(scope, dependencies) request_endpoint(@rubygem.name, @version.number) end should respond_with :success + should "render gem name" do assert page.has_content?(@rubygem.name) end @@ -58,6 +64,16 @@ def render_str_call(scope, dependencies) refute page.has_content?(@dependency.name) end end + + context "with an invalid version that makes a valid gem full name" do + setup do + prefix = create(:version, rubygem: build(:rubygem, name: "foo"), number: "0.1.0", platform: "ruby") + create(:version, rubygem: build(:rubygem, name: "foo-bar"), number: "0.1.0", platform: "ruby") + request_endpoint(prefix.rubygem.name, "bar-0.1.0") + end + + should respond_with :not_found + end end context "GET to show in json" do @@ -71,7 +87,7 @@ def render_str_call(scope, dependencies) rubygem: @dep_rubygem_two, version: @version) - request_endpoint(@rubygem.name, @version.number, "json") + request_endpoint(@rubygem.name, @version.slug, "json") @response = JSON.parse(@response.body) end @@ -83,8 +99,9 @@ def render_str_call(scope, dependencies) } run = render_str_call("runtime", dependencies) dev = render_str_call("development", dependencies) - assert_equal @response["run_html"], run - assert_equal @response["dev_html"], dev + + assert_equal run, @response["run_html"] + assert_equal dev, @response["dev_html"] end end end diff --git a/test/functional/email_confirmations_controller_test.rb b/test/functional/email_confirmations_controller_test.rb index af3b50f3895..6f5a31fccaa 100644 --- a/test/functional/email_confirmations_controller_test.rb +++ b/test/functional/email_confirmations_controller_test.rb @@ -1,17 +1,42 @@ require "test_helper" class EmailConfirmationsControllerTest < ActionController::TestCase + include ActionMailer::TestHelper + include ActiveJob::TestHelper + context "on GET to update" do + setup { @user = create(:user, :unconfirmed) } + context "user exists and token has not expired" do setup do - @user = create(:user) get :update, params: { token: @user.confirmation_token } end should "should confirm user account" do - assert @user.email_confirmed + assert @user.reload.email_confirmed + end + should "not sign in user" do + refute cookies[:remember_token] + end + should "instruct the browser not to send referrer that contains the token" do + assert_equal "no-referrer", response.headers["Referrer-Policy"] + end + end + + context "successful confirmation while signed in" do + setup do + @user.confirm_email! # must be confirmed to sign in + sign_in_as(@user) + @user.update!(unconfirmed_email: "new@example.com") + get :update, params: { token: @user.confirmation_token } + end + + should redirect_to("the dashboard") { dashboard_url } + + should "should confirm user account" do + assert @user.reload.email_confirmed end - should "sign in user" do + should "keep the user signed in" do assert cookies[:remember_token] end end @@ -20,27 +45,394 @@ class EmailConfirmationsControllerTest < ActionController::TestCase setup { get :update, params: { token: Clearance::Token.new } } should "warn about invalid url" do - assert_equal flash[:alert], "Please double check the URL or try submitting it again." + assert_equal "Please double check the URL or try submitting it again.", flash[:alert] end should "not sign in user" do refute cookies[:remember_token] end end + context "array of tokens" do + setup do + get :update, params: { token: [@user.confirmation_token, Clearance::Token.new, Clearance::Token.new] } + end + + should respond_with :bad_request + + should "not sign in user" do + refute cookies[:remember_token] + end + end + context "token has expired" do setup do - user = create(:user) - user.update_attribute("token_expires_at", 2.minutes.ago) - get :update, params: { token: user.confirmation_token } + @user.update_attribute("token_expires_at", 2.minutes.ago) + get :update, params: { token: @user.confirmation_token } end should "warn about invalid url" do - assert_equal flash[:alert], "Please double check the URL or try submitting it again." + assert_equal "Please double check the URL or try submitting it again.", flash[:alert] end should "not sign in user" do refute cookies[:remember_token] end end + + context "mutliple user has same unconfirmed email" do + setup do + @email = "some@email.com" + @user.update_attribute(:unconfirmed_email, @email) + @second_user = create(:user, unconfirmed_email: @email) + get :update, params: { token: @user.confirmation_token } + end + + should redirect_to("the sign in page") { sign_in_url } + + should "confirm email for first user" do + assert_equal @email, @user.reload.email + end + + context "second user sends confirmation request" do + setup do + get :update, params: { token: @second_user.confirmation_token } + end + + should "show error to second user on confirmation request" do + assert_equal "Email address has already been taken", flash[:alert] + end + + should "not confirm email for first user" do + assert_predicate @second_user, :unconfirmed_email? + refute_equal @email, @second_user.reload.email + end + end + end + + context "user has totp enabled" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + get :update, params: { token: @user.confirmation_token } + end + + should respond_with :success + + should "display otp form" do + assert page.has_content?("Multi-factor authentication") + assert page.has_content?("OTP or recovery code") + end + end + + context "user has webauthn enabled but no recovery codes" do + setup do + create(:webauthn_credential, user: @user) + @user.new_mfa_recovery_codes = nil + @user.mfa_hashed_recovery_codes = [] + @user.save! + get :update, params: { token: @user.confirmation_token } + end + + should respond_with :success + + should "display webauthn form" do + assert page.has_content?("Multi-factor authentication") + assert page.has_button?("Authenticate with security device") + end + + should "not display recovery code prompt" do + refute page.has_content?("Recovery code") + end + end + + context "user has webauthn enabled and recovery codes" do + setup do + create(:webauthn_credential, user: @user) + get :update, params: { token: @user.confirmation_token } + end + + should respond_with :success + + should "display webauthn form" do + assert page.has_content?("Multi-factor authentication") + assert page.has_button?("Authenticate with security device") + end + + should "display recovery code prompt" do + assert page.has_content?("Recovery code") + end + end + + context "when user has webauthn and totp" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + create(:webauthn_credential, user: @user) + get :update, params: { token: @user.confirmation_token } + end + + should respond_with :success + + should "display webauthn prompt" do + assert page.has_button?("Authenticate with security device") + end + + should "display otp prompt" do + assert page.has_content?("OTP or recovery code") + end + end + end + + context "on POST to otp_update" do + context "user has mfa enabled" do + setup do + @user = create(:user, :unconfirmed) + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + end + + context "when OTP is correct" do + setup do + get :update, params: { token: @user.confirmation_token } + post :otp_update, params: { token: @user.confirmation_token, otp: ROTP::TOTP.new(@user.totp_seed).now } + end + + should set_flash[:notice] + should redirect_to("the sign in page") { sign_in_url } + + should "should confirm user account" do + assert @user.reload.email_confirmed + end + + should "clear mfa_expires_at" do + assert_nil @controller.session[:mfa_expires_at] + end + end + + context "user is already signed in and OTP is correct" do + setup do + @user.confirm_email! + sign_in_as(@user) + @user.update!(unconfirmed_email: "new@example.com") + + assert @user.confirmation_token + get :update, params: { token: @user.confirmation_token } + post :otp_update, params: { token: @user.confirmation_token, otp: ROTP::TOTP.new(@user.totp_seed).now } + end + + should redirect_to("the dashboard") { dashboard_url } + + should "should confirm user account" do + assert @user.reload.email_confirmed + end + should "keep the user signed in" do + assert cookies[:remember_token] + end + end + + context "when OTP is incorrect" do + setup do + get :update, params: { token: @user.confirmation_token } + post :otp_update, params: { token: @user.confirmation_token, otp: "incorrect" } + end + + should respond_with :unauthorized + + should "alert about otp being incorrect" do + assert_equal "Your OTP code is incorrect.", flash[:alert] + end + end + + context "when the OTP session is expired" do + setup do + get :update, params: { token: @user.confirmation_token } + travel 16.minutes do + post :otp_update, params: { token: @user.confirmation_token, otp: ROTP::TOTP.new(@user.totp_seed).now } + end + end + + should set_flash.now[:alert] + should respond_with :unauthorized + + should "clear mfa_expires_at" do + assert_nil @controller.session[:mfa_expires_at] + end + + should "render sign in page" do + assert page.has_content? "Sign in" + end + + should "not sign in the user" do + refute_predicate @controller.request.env[:clearance], :signed_in? + end + end + end + end + + context "on POST to webauthn_update" do + setup do + @user = create(:user, :unconfirmed) + @webauthn_credential = create(:webauthn_credential, user: @user) + get :update, params: { token: @user.confirmation_token } + @origin = WebAuthn.configuration.origin + @rp_id = URI.parse(@origin).host + @client = WebAuthn::FakeClient.new(@origin, encoding: false) + end + + context "with webauthn enabled" do + setup do + @challenge = session[:webauthn_authentication]["challenge"] + WebauthnHelpers.create_credential( + webauthn_credential: @webauthn_credential, + client: @client + ) + post( + :webauthn_update, + params: { + token: @user.confirmation_token, + credentials: + WebauthnHelpers.get_result( + client: @client, + challenge: @challenge + ) + } + ) + end + + should redirect_to("the sign in page") { sign_in_url } + + should "change the user's email" do + assert @user.reload.email_confirmed + end + + should "clear mfa_expires_at" do + assert_nil @controller.session[:mfa_expires_at] + end + + should "set flash notice" do + assert_equal "Your email address has been verified.", flash[:notice] + end + end + + context "while signed in with successful webauthn" do + setup do + @user.confirm_email! + sign_in_as(@user) + @user.update!(unconfirmed_email: "new@example.com") + @challenge = session[:webauthn_authentication]["challenge"] + WebauthnHelpers.create_credential( + webauthn_credential: @webauthn_credential, + client: @client + ) + post( + :webauthn_update, + params: { + token: @user.confirmation_token, + credentials: + WebauthnHelpers.get_result( + client: @client, + challenge: @challenge + ) + } + ) + end + + should redirect_to("the dashboard") { dashboard_url } + + should "change the user's email" do + assert @user.reload.email_confirmed + assert_equal "new@example.com", @user.email + end + + should "clear mfa_expires_at" do + assert_nil @controller.session[:mfa_expires_at] + end + + should "set flash notice" do + assert_equal "Your email address has been verified.", flash[:notice] + end + end + + context "when not providing credentials" do + setup do + post( + :webauthn_update, + params: { + token: @user.confirmation_token + } + ) + end + + should respond_with :unauthorized + + should "set flash notice" do + assert_equal "Credentials required", flash[:alert] + end + end + + context "when providing wrong credential" do + setup do + @wrong_challenge = SecureRandom.hex + WebauthnHelpers.create_credential( + webauthn_credential: @webauthn_credential, + client: @client + ) + post( + :webauthn_update, + params: { + token: @user.confirmation_token, + credentials: + WebauthnHelpers.get_result( + client: @client, + challenge: @wrong_challenge + ) + } + ) + end + + should respond_with :unauthorized + + should "set flash notice" do + assert_equal "WebAuthn::ChallengeVerificationError", flash[:alert] + end + should "still have the webauthn form url" do + assert_not_nil page.find(".js-webauthn-session--form")[:action] + end + end + + context "when webauthn session is expired" do + setup do + @challenge = session[:webauthn_authentication]["challenge"] + WebauthnHelpers.create_credential( + webauthn_credential: @webauthn_credential, + client: @client + ) + travel 16.minutes do + post( + :webauthn_update, + params: { + token: @user.confirmation_token, + credentials: + WebauthnHelpers.get_result( + client: @client, + challenge: @challenge + ) + } + ) + end + end + + should respond_with :unauthorized + should set_flash.now[:alert] + + should "clear mfa_expires_at" do + assert_nil @controller.session[:mfa_expires_at] + end + + should "render sign in page" do + assert page.has_content? "Sign in" + end + + should "not sign in the user" do + refute_predicate @controller.request.env[:clearance], :signed_in? + end + end end context "on GET to new" do @@ -59,31 +451,47 @@ class EmailConfirmationsControllerTest < ActionController::TestCase context "user exists" do setup do create(:user, email: "foo@bar.com") - post :create, params: { email_confirmation: { email: "foo@bar.com" } } - Delayed::Worker.new.work_off + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + post :create, params: { email_confirmation: { email: "foo@bar.com" } } + end end should respond_with :redirect should redirect_to("the homepage") { root_url } should "deliver confirmation email" do - refute ActionMailer::Base.deliveries.empty? + refute_empty ActionMailer::Base.deliveries email = ActionMailer::Base.deliveries.last + assert_equal ["foo@bar.com"], email.to assert_equal ["no-reply@mailer.rubygems.org"], email.from assert_equal "Please confirm your email address with RubyGems.org", email.subject end should "promise to send email if account exists" do - assert_equal flash[:notice], "We will email you confirmation link to activate your account if one exists." + assert_equal "We will email you confirmation link to activate your account if one exists.", flash[:notice] + end + end + + context "invalid params" do + should "fail friendly" do + post :create, params: { email_confirmation: "ABC" } + + assert_response :bad_request # bad status raised by strong params + end + + should "handle non-scalar params" do + post :create, params: { email_confirmation: { email: { foo: "bar" } } } + + assert_response :bad_request # bad status raised by strong params end end context "user does not exist" do should "not deliver confirmation email" do - Mailer.expects(:email_confirmation).times(0) post :create, params: { email_confirmation: { email: "someone@else.com" } } - Delayed::Worker.new.work_off + + assert_no_enqueued_emails end end end @@ -92,8 +500,9 @@ class EmailConfirmationsControllerTest < ActionController::TestCase context "user is not signed in" do should "not send confirmation mail" do Mailer.expects(:email_reset).times(0) - post :unconfirmed - Delayed::Worker.new.work_off + perform_enqueued_jobs do + post :unconfirmed + end end should "redirect to sign in page" do @@ -106,26 +515,208 @@ class EmailConfirmationsControllerTest < ActionController::TestCase context "user is signed in" do setup do - @user = create(:user, confirmation_token: "something") + @user = create(:user, confirmation_token: "something", unconfirmed_email: "new@example.com") sign_in_as(@user) end - should "regenerate confirmation token" do - post :unconfirmed - assert_not_equal "something", @user.reload.confirmation_token + context "on successful token generation" do + should "regenerate confirmation token" do + post :unconfirmed + + assert_not_equal "something", @user.reload.confirmation_token + end + + should "send confirmation mail" do + assert_enqueued_email_with Mailer, :email_reset, args: [@user] do + post :unconfirmed + end + end + + should "set success flash and redirect to edit path" do + post :unconfirmed + + assert_redirected_to edit_profile_path + expected_notice = "You will receive an email within the next few minutes. It contains instructions for confirming your new email address." + + assert_equal expected_notice, flash[:notice] + end end - should "send confirmation mail" do - Mailer.expects(:email_reset).times(1) - post :unconfirmed - Delayed::Worker.new.work_off + context "on failed confirmation token save" do + setup do + post :unconfirmed + @user.stubs(:save).returns(false) + end + + should redirect_to("the edit settings page") { edit_profile_path } + + should "set error flash" do + post :unconfirmed + + assert_equal "Something went wrong. Please try again.", flash[:notice] + end end - should "set success flash and redirect to edit path" do - post :unconfirmed - assert_redirected_to edit_profile_path - expected_notice = "You will receive an email within the next few minutes. It contains instructions for confirming your new email address." - assert_equal expected_notice, flash[:notice] + context "when user owns a gem with more than MFA_REQUIRED_THRESHOLD downloads" do + setup do + @rubygem = create(:rubygem) + create(:ownership, rubygem: @rubygem, user: @user) + GemDownload.increment( + Rubygem::MFA_REQUIRED_THRESHOLD + 1, + rubygem_id: @rubygem.id + ) + end + + context "user has mfa disabled" do + context "on GET to update" do + setup do + get :update, params: { token: @user.confirmation_token } + end + + should "should confirm user account" do + assert @user.reload.email_confirmed + end + end + + context "on POST to otp_update" do + setup do + post :otp_update, params: { token: @user.confirmation_token, otp: "incorrect" } + end + + should respond_with :unauthorized + end + + context "on PATCH to unconfirmed" do + setup { patch :unconfirmed } + should redirect_to("the edit settings page") { edit_settings_path } + + should "set mfa_redirect_uri" do + assert_equal unconfirmed_email_confirmations_path, session[:mfa_redirect_uri] + end + end + + context "on GET to new" do + setup { get :new } + should "not redirect to mfa" do + assert_response :success + assert page.has_content? "Resend confirmation email" + end + end + + context "on POST to create" do + setup do + create(:user, email: "foo@bar.com") + perform_enqueued_jobs do + post :create, params: { email_confirmation: { email: "foo@bar.com" } } + end + end + + should respond_with :redirect + should redirect_to("the homepage") { root_url } + end + end + + context "user has mfa set to weak level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + end + + context "on GET to update" do + setup do + get :update, params: { token: @user.confirmation_token } + end + + should "should confirm user account" do + assert @user.reload.email_confirmed + end + end + + context "on POST to otp_update" do + setup do + post :otp_update, params: { token: @user.confirmation_token, otp: "incorrect" } + end + + should respond_with :unauthorized + end + + context "on PATCH to unconfirmed" do + setup { patch :unconfirmed } + should redirect_to("the edit settings page") { edit_settings_path } + + should "set mfa_redirect_uri" do + assert_equal unconfirmed_email_confirmations_path, session[:mfa_redirect_uri] + end + end + + context "on GET to new" do + setup { get :new } + should "not redirect to mfa" do + assert_response :success + assert page.has_content? "Resend confirmation email" + end + end + + context "on POST to create" do + setup do + create(:user, email: "foo@bar.com") + perform_enqueued_jobs do + post :create, params: { email_confirmation: { email: "foo@bar.com" } } + end + end + + should respond_with :redirect + should redirect_to("the homepage") { root_url } + end + end + + context "user has MFA set to strong level, expect normal behaviour" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + end + + context "on GET to update" do + setup do + get :update, params: { token: @user.confirmation_token } + end + + should "should confirm user account" do + assert @user.reload.email_confirmed + end + end + + context "on POST to otp_update" do + setup do + post :otp_update, params: { token: @user.confirmation_token, otp: "incorrect" } + end + + should respond_with :unauthorized + end + + context "on PATCH to unconfirmed" do + setup { patch :unconfirmed } + should redirect_to("edit profile page") { edit_profile_path } + end + + context "on GET to new" do + setup { get :new } + should "not redirect to mfa" do + assert_response :success + assert page.has_content? "Resend confirmation email" + end + end + + context "on POST to create" do + setup do + create(:user, email: "foo@bar.com") + perform_enqueued_jobs do + post :create, params: { email_confirmation: { email: "foo@bar.com" } } + end + end + + should respond_with :redirect + should redirect_to("the homepage") { root_url } + end + end end end end diff --git a/test/functional/home_controller_test.rb b/test/functional/home_controller_test.rb index f8ce507f6be..7f6d675b59b 100644 --- a/test/functional/home_controller_test.rb +++ b/test/functional/home_controller_test.rb @@ -15,14 +15,16 @@ class HomeControllerTest < ActionController::TestCase end should "on GET to index with non html accept header" do + @request.env["HTTP_ACCEPT"] = "image/gif, image/x-bitmap, image/jpeg, image/pjpeg" + assert_raises(ActionController::UnknownFormat) do - @request.env["HTTP_ACCEPT"] = "image/gif, image/x-bitmap, image/jpeg, image/pjpeg" get :index end end should "use default locale on GET using invalid one" do get :index, params: { locale: "foobar" } - assert_equal I18n.locale, I18n.default_locale + + assert_equal I18n.default_locale, I18n.locale end end diff --git a/test/functional/internal/ping_controller_test.rb b/test/functional/internal/ping_controller_test.rb index fb2bea6706d..f9cb71be732 100644 --- a/test/functional/internal/ping_controller_test.rb +++ b/test/functional/internal/ping_controller_test.rb @@ -28,9 +28,10 @@ class Internal::PingControllerTest < ActionController::TestCase f = mock f.expects(:read).raises(Errno::ENOENT) AppRevision.expects(:revision_file).returns(f) - AppRevision.expects("`".to_sym).with("git rev-parse HEAD").returns("SOMESHAFROMGIT\n") + AppRevision.expects(:`).with("git rev-parse HEAD").returns("SOMESHAFROMGIT\n") get :revision + assert_response :ok assert_equal "SOMESHAFROMGIT", @response.body end @@ -39,6 +40,7 @@ class Internal::PingControllerTest < ActionController::TestCase AppRevision.stubs(revision_file: stub(read: "SOMESHA\n")) get :revision + assert_response :ok assert_equal "SOMESHA", @response.body end diff --git a/test/functional/multifactor_auths_controller_test.rb b/test/functional/multifactor_auths_controller_test.rb index 9293e2a47b6..aab7a260730 100644 --- a/test/functional/multifactor_auths_controller_test.rb +++ b/test/functional/multifactor_auths_controller_test.rb @@ -1,6 +1,8 @@ require "test_helper" class MultifactorAuthsControllerTest < ActionController::TestCase + include ActionMailer::TestHelper + context "when logged in" do setup do @user = create(:user) @@ -8,157 +10,488 @@ class MultifactorAuthsControllerTest < ActionController::TestCase @request.cookies[:mfa_feature] = "true" end - context "when mfa enabled" do + context "when totp is enabled" do setup do - @user.enable_mfa!(ROTP::Base32.random_base32, :ui_only) + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) end - context "on GET to new mfa" do + context "on PUT to update mfa level" do setup do - get :new + freeze_time + put :update, params: { level: "ui_and_api" } end - should respond_with :redirect - should redirect_to("the settings page") { edit_settings_path } - end + should "render totp prompt" do + assert page.has_content?("OTP code") + refute page.has_content?("Security Device") + end - context "on POST to create mfa" do - setup do - post :create, params: { otp: ROTP::TOTP.new(@user.mfa_seed).now } + should "not update mfa level" do + assert_predicate @user.reload, :mfa_ui_only? end - should respond_with :redirect - should redirect_to("the settings page") { edit_settings_path } - should "keep mfa enabled" do - assert @user.reload.mfa_enabled? + should "set expiry in session" do + assert_equal 15.minutes.from_now.to_s, session[:mfa_expires_at] + end + + teardown do + travel_back end end - context "on PUT to update mfa level" do - context "on disabling mfa" do - context "when otp code is correct" do + context "on POST to otp_update" do + context "when updating to ui_and_api" do + context "when redirect url is not set" do setup do - put :update, params: { otp: ROTP::TOTP.new(@user.mfa_seed).now, level: "disabled" } + put :update, params: { level: "ui_and_api" } + post :otp_update, params: { otp: ROTP::TOTP.new(@user.totp_seed).now, level: "ui_and_api" } end - should respond_with :redirect should redirect_to("the settings page") { edit_settings_path } - should "disable mfa" do - refute @user.reload.mfa_enabled? + + should "update mfa level" do + assert_predicate @user.reload, :mfa_ui_and_api? + end + + should "clear session variables" do + assert_nil @controller.session[:mfa_expires_at] end end - context "when otp is recovery code" do + context "when redirect url is set" do setup do - put :update, params: { otp: @user.mfa_recovery_codes.first, level: "disabled" } + @controller.session["mfa_redirect_uri"] = profile_api_keys_path + put :update, params: { level: "ui_and_api" } + post :otp_update, params: { otp: ROTP::TOTP.new(@user.totp_seed).now, level: "ui_and_api" } end - should respond_with :redirect - should redirect_to("the settings page") { edit_settings_path } - should "disable mfa" do - refute @user.reload.mfa_enabled? - end + should redirect_to("the api keys index") { profile_api_keys_path } end + end - context "when otp code is incorrect" do + context "when updating to ui_and_gem_signin" do + context "when redirect url is not set" do setup do - wrong_otp = (ROTP::TOTP.new(@user.mfa_seed).now.to_i.succ % 1_000_000).to_s - put :update, params: { otp: wrong_otp, level: "disabled" } + put :update, params: { level: "ui_and_gem_signin" } + post :otp_update, params: { otp: ROTP::TOTP.new(@user.totp_seed).now, level: "ui_and_gem_signin" } end - should respond_with :redirect should redirect_to("the settings page") { edit_settings_path } - should set_flash.to("Your OTP code is incorrect.") - should "keep mfa enabled" do - assert @user.reload.mfa_enabled? + + should "update mfa level" do + assert_predicate @user.reload, :mfa_ui_and_gem_signin? + end + + should "clear session variables" do + assert_nil @controller.session[:mfa_expires_at] + end + end + + context "when redirect url is set" do + setup do + @controller.session["mfa_redirect_uri"] = profile_api_keys_path + put :update, params: { level: "ui_and_api" } + post :otp_update, params: { otp: ROTP::TOTP.new(@user.totp_seed).now, level: "ui_and_api" } end + + should redirect_to("the api keys index") { profile_api_keys_path } end end - context "on updating to ui_only" do + context "when otp is incorrect" do setup do - @user.mfa_ui_and_api! - put :update, params: { otp: ROTP::TOTP.new(@user.mfa_seed).now, level: "ui_only" } + put :update, params: { level: "ui_and_api" } + post :otp_update, params: { otp: "123456", level: "ui_and_api" } end - should respond_with :redirect should redirect_to("the settings page") { edit_settings_path } - should "update mfa level to mfa_ui_only now" do - assert @user.reload.mfa_ui_only? + + should "not update mfa level" do + assert_predicate @user.reload, :mfa_ui_only? + end + + should "set flash error" do + assert_equal "Your OTP code is incorrect.", flash[:error] + end + + should "clear session variables" do + assert_nil @controller.session[:mfa_expires_at] + assert_nil @controller.session[:mfa_redirect_uri] end end - context "on updating to ui_and_api" do + context "when mfa level is invalid" do setup do - put :update, params: { otp: ROTP::TOTP.new(@user.mfa_seed).now, level: "ui_and_api" } + put :update, params: { level: "disabled" } + post :otp_update, params: { otp: ROTP::TOTP.new(@user.totp_seed).now, level: "disabled" } end - should respond_with :redirect - should redirect_to("the settings page") { edit_settings_path } - should "update make mfa level to mfa_ui_and_api now" do - assert @user.reload.mfa_ui_and_api? + should "set flash error" do + assert_equal "Invalid MFA level.", flash[:error] end + + should redirect_to("the settings page") { edit_settings_path } end - context "on updating to ui_and_gem_signin" do + context "when session is expired" do setup do - put :update, params: { otp: ROTP::TOTP.new(@user.mfa_seed).now, level: "ui_and_gem_signin" } + get :update, params: { level: "ui_and_api" } + + travel 16.minutes do + post :otp_update, params: { otp: ROTP::TOTP.new(@user.totp_seed).now } + end end - should respond_with :redirect should redirect_to("the settings page") { edit_settings_path } - should "update make mfa level to mfa_ui_and_gem_signin now" do - assert @user.reload.mfa_ui_and_gem_signin? + + should "not update mfa level" do + assert_predicate @user.reload, :mfa_ui_only? + end + + should "set flash error" do + assert_equal "Your login page session has expired.", flash[:error] + end + + should "clear session variables" do + assert_nil @controller.session[:mfa_expires_at] + assert_nil @controller.session[:mfa_redirect_uri] end end end + + context "on POST to webauthn_update" do + setup do + put :update, params: { level: "ui_and_api" } + post :webauthn_update, params: { level: "ui_and_api" } + end + + should redirect_to("the settings page") { edit_settings_path } + + should "set flash error" do + assert_equal "You don't have any security devices enabled. " \ + "You have to associate a device to your account first.", flash[:error] + end + + should "not update mfa level" do + assert_predicate @user.reload, :mfa_ui_only? + end + + should "clear session variables" do + assert_nil @controller.session[:mfa_expires_at] + assert_nil @controller.session[:mfa_redirect_uri] + end + end end - context "when mfa disabled" do + context "when a webauthn device is enabled" do setup do - @user.mfa_disabled! + @webauthn_credential = create(:webauthn_credential, user: @user) + @user.update!(mfa_level: :ui_only) end - context "on POST to create mfa" do + context "on PUT to update mfa level" do setup do - @seed = ROTP::Base32.random_base32 - @controller.session[:mfa_seed] = @seed + freeze_time + end + + context "when user has recovery codes" do + setup do + put :update, params: { level: "ui_and_api" } + end + + should "render webauthn prompt" do + refute page.has_content?("OTP code") + assert page.has_content?("Security Device") + end + + should "render recovery code prompt" do + assert page.has_content?("Recovery code") + end + + should "not update mfa level" do + assert_predicate @user.reload, :mfa_ui_only? + end + + should "set expiry in session" do + assert_equal 15.minutes.from_now.to_s, session[:mfa_expires_at] + end end - context "when qr-code is not expired" do + context "when user does not have recovery codes" do setup do - @controller.session[:mfa_seed_expire] = Gemcutter::MFA_KEY_EXPIRY.from_now.utc.to_i - post :create, params: { otp: ROTP::TOTP.new(@seed).now } + @user.update!(mfa_hashed_recovery_codes: []) + @user.new_mfa_recovery_codes = nil + put :update, params: { level: "ui_and_api" } + end + + should "not render recovery code prompt" do + refute page.has_content?("Recovery code") + end + end + + teardown do + travel_back + end + end + + context "on POST to otp_update with correct recovery codes" do + setup do + put :update, params: { level: "ui_and_api" } + post :otp_update, params: { otp: @user.new_mfa_recovery_codes.first, level: "ui_and_api" } + end + + should redirect_to("the settings page") { edit_settings_path } + + should "update mfa level" do + assert_predicate @user.reload, :mfa_ui_and_api? + end + + should "clear session variables" do + assert_nil @controller.session[:mfa_expires_at] + assert_nil @controller.session[:level] + assert_nil @controller.session[:webauthn_authentication] + end + end + + context "on POST to otp_update with incorrect recovery codes" do + setup do + put :update, params: { level: "ui_and_api" } + post :otp_update, params: { otp: "blah", level: "ui_and_api" } + end + + should redirect_to("the settings page") { edit_settings_path } + + should "not update mfa level" do + assert_predicate @user.reload, :mfa_ui_only? + end + + should "set flash error" do + assert_equal "Your OTP code is incorrect.", flash[:error] + end + end + + context "on POST to webauthn_update" do + setup do + @origin = WebAuthn.configuration.origin + @rp_id = URI.parse(@origin).host + @client = WebAuthn::FakeClient.new(@origin, encoding: false) + WebauthnHelpers.create_credential( + webauthn_credential: @webauthn_credential, + client: @client + ) + end + + context "when updating to ui and api" do + setup do + put :update, params: { level: "ui_and_api" } + @challenge = session[:webauthn_authentication]["challenge"] + end + + context "redirect url is not set" do + setup do + post( + :webauthn_update, + params: { + level: "ui_and_api", + credentials: + WebauthnHelpers.get_result( + client: @client, + challenge: @challenge + ) + } + ) + end + + should redirect_to("the settings page") { edit_settings_path } + + should "update mfa level" do + assert_predicate @user.reload, :mfa_ui_and_api? + end + + should "clear session variables" do + assert_nil @controller.session[:mfa_expires_at] + assert_nil @controller.session[:level] + assert_nil @controller.session[:webauthn_authentication] + end + end + + context "when redirect url is set" do + setup do + @controller.session["mfa_redirect_uri"] = profile_api_keys_path + post( + :webauthn_update, + params: { + level: "ui_and_api", + credentials: + WebauthnHelpers.get_result( + client: @client, + challenge: @challenge + ) + } + ) + end + + should redirect_to("the api keys index") { profile_api_keys_path } + end + end + + context "when updating to ui and gem signin" do + setup do + put :update, params: { level: "ui_and_gem_signin" } + @challenge = session[:webauthn_authentication]["challenge"] + end + + context "redirect url is not set" do + setup do + post( + :webauthn_update, + params: { + level: "ui_and_gem_signin", + credentials: + WebauthnHelpers.get_result( + client: @client, + challenge: @challenge + ) + } + ) + end + + should redirect_to("the settings page") { edit_settings_path } + + should "update mfa level" do + assert_predicate @user.reload, :mfa_ui_and_gem_signin? + end + + should "clear session variables" do + assert_nil @controller.session[:mfa_expires_at] + assert_nil @controller.session[:level] + assert_nil @controller.session[:webauthn_authentication] + end end - should respond_with :success - should "show recovery codes" do - @user.reload.mfa_recovery_codes.each do |code| - assert page.has_content?(code) + context "when redirect url is set" do + setup do + @controller.session["mfa_redirect_uri"] = profile_api_keys_path + post( + :webauthn_update, + params: { + level: "ui_and_api", + credentials: + WebauthnHelpers.get_result( + client: @client, + challenge: @challenge + ) + } + ) end + + should redirect_to("the api keys index") { profile_api_keys_path } + end + end + + context "when not providing credentials" do + setup do + put :update, params: { level: "ui_and_api" } + post :webauthn_update, params: { level: "ui_and_api" } + end + + should redirect_to("the settings page") { edit_settings_path } + + should "set flash error" do + assert_equal "Credentials required", flash[:error] + end + end + + context "when providing wrong credential" do + setup do + put :update, params: { level: "ui_and_api" } + @wrong_challenge = SecureRandom.hex + post( + :webauthn_update, + params: { + level: "ui_and_api", + credentials: + WebauthnHelpers.get_result( + client: @client, + challenge: @wrong_challenge + ) + } + ) end - should "enable mfa" do - assert @user.reload.mfa_enabled? + + should redirect_to("the settings page") { edit_settings_path } + + should "set flash notice" do + assert_equal "WebAuthn::ChallengeVerificationError", flash[:error] + end + + should "clear session variables" do + assert_nil @controller.session[:mfa_expires_at] + assert_nil @controller.session[:level] + assert_nil @controller.session[:webauthn_authentication] end end - context "when qr-code is expired" do + context "when webauthn session is expired" do setup do - @controller.session[:mfa_seed_expire] = 1.minute.ago - post :create, params: { otp: ROTP::TOTP.new(@seed).now } + put :update, params: { level: "ui_and_api" } + @challenge = session[:webauthn_authentication]["challenge"] + travel 16.minutes do + post( + :webauthn_update, + params: { + level: "ui_and_api", + credentials: + WebauthnHelpers.get_result( + client: @client, + challenge: @challenge + ) + } + ) + end end - should respond_with :redirect should redirect_to("the settings page") { edit_settings_path } - should "set error flash message" do - refute_empty flash[:error] + + should "set flash error" do + assert_equal "Your login page session has expired.", flash[:error] + end + + should "clear session variables" do + assert_nil @controller.session[:mfa_expires_at] + assert_nil @controller.session[:level] + assert_nil @controller.session[:webauthn_authentication] + end + end + + context "to update to invalid level" do + setup do + put :update, params: { level: "disabled" } + @challenge = session[:webauthn_authentication]["challenge"] end - should "keep mfa disabled" do - refute @user.reload.mfa_enabled? + + should "not update level and display flash error" do + assert_no_changes -> { @user.reload.mfa_level } do + post( + :webauthn_update, + params: { + level: "disabled", + credentials: + WebauthnHelpers.get_result( + client: @client, + challenge: @challenge + ) + } + ) + end + + assert_equal "Invalid MFA level.", flash[:error] end end end + end + context "when there are no mfa devices" do context "on PUT to update mfa level" do setup do put :update @@ -166,8 +499,212 @@ class MultifactorAuthsControllerTest < ActionController::TestCase should respond_with :redirect should redirect_to("the settings page") { edit_settings_path } + + should "keep mfa disabled" do + refute_predicate @user.reload, :mfa_enabled? + end + + should "say MFA is not enabled" do + assert_equal "Your multi-factor authentication has not been enabled. " \ + "You have to enable it first.", flash[:error] + end + end + + context "on POST to otp_update" do + setup do + post :otp_update + end + + should respond_with :redirect + should redirect_to("the settings page") { edit_settings_path } + should "keep mfa disabled" do - refute @user.reload.mfa_enabled? + refute_predicate @user.reload, :mfa_enabled? + end + + should "say MFA is not enabled" do + assert_equal "Your multi-factor authentication has not been enabled. You have to enable it first.", flash[:error] + end + end + + context "on POST to webauthn_update" do + setup do + @controller.create_new_mfa_expiry + post :webauthn_update + end + + should redirect_to("the settings page") { edit_settings_path } + + should "set flash error" do + assert_equal "You don't have any security devices enabled. " \ + "You have to associate a device to your account first.", flash[:error] + end + end + end + + context "when totp and webauthn are enabled" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + @webauthn_credential = create(:webauthn_credential, user: @user) + end + + context "on PUT to update mfa level" do + setup do + put :update, params: { level: "ui_and_api" } + end + + should "render totp prompt" do + assert page.has_content?("OTP code") + end + + should "render webauthn prompt" do + assert page.has_content?("Security Device") + end + end + + context "on POST to otp_update" do + setup do + @controller.session["mfa_redirect_uri"] = profile_api_keys_path + put :update, params: { level: "ui_and_api" } + post :otp_update, params: { otp: ROTP::TOTP.new(@user.totp_seed).now, level: "ui_and_api" } + end + + should redirect_to("the api keys index") { profile_api_keys_path } + + should "update mfa level" do + assert_predicate @user.reload, :mfa_ui_and_api? + end + + should "clear session variables" do + assert_nil @controller.session[:mfa_expires_at] + assert_nil @controller.session[:level] + end + end + + context "on POST to webauthn_update" do + setup do + origin = WebAuthn.configuration.origin + @rp_id = URI.parse(origin).host + @client = WebAuthn::FakeClient.new(origin, encoding: false) + WebauthnHelpers.create_credential( + webauthn_credential: @webauthn_credential, + client: @client + ) + @controller.session["mfa_redirect_uri"] = profile_api_keys_path + put :update, params: { level: "ui_and_api" } + post( + :webauthn_update, + params: { + level: "ui_and_api", + credentials: + WebauthnHelpers.get_result( + client: @client, + challenge: session[:webauthn_authentication]["challenge"] + ) + } + ) + end + + should redirect_to("the api keys index") { profile_api_keys_path } + + should "update mfa level" do + assert_predicate @user.reload, :mfa_ui_and_api? + end + + should "clear session variables" do + assert_nil @controller.session[:mfa_expires_at] + assert_nil @controller.session[:level] + end + end + end + + context "on GET to recovery" do + context "when show_recovery_codes is array" do + setup do + @controller.session[:show_recovery_codes] = %w[aaa bbb] + get :recovery + end + + should respond_with :success + + should "clear show_recovery_codes" do + assert_nil @controller.session[:show_recovery_codes] + end + end + + context "when show_recovery_codes is not set" do + setup do + get :recovery + end + + should respond_with :redirect + should redirect_to("the settings page") { edit_settings_path } + + should "set error flash message" do + assert_equal "You should have already saved your recovery codes.", flash[:error] + end + end + end + + context "when user owns a gem with more than MFA_REQUIRED_THRESHOLD downloads" do + setup do + @rubygem = create(:rubygem) + create(:ownership, rubygem: @rubygem, user: @user) + GemDownload.increment( + Rubygem::MFA_REQUIRED_THRESHOLD + 1, + rubygem_id: @rubygem.id + ) + @redirect_paths = [adoptions_profile_path, + dashboard_path, + delete_profile_path, + edit_profile_path, + new_profile_api_key_path, + notifier_path, + profile_api_keys_path, + verify_session_path] + end + + context "user has mfa set to weak level" do + setup do + @seed = ROTP::Base32.random_base32 + @user.enable_totp!(@seed, :ui_only) + end + + should "redirect user back to mfa_redirect_uri after successful mfa setup" do + @redirect_paths.each do |path| + session[:mfa_redirect_uri] = path + put :update, params: { level: "ui_and_api" } + put :otp_update, params: { otp: ROTP::TOTP.new(@seed).now, level: "ui_and_api" } + + assert_redirected_to path + assert_nil session[:mfa_redirect_uri] + end + end + + should "not redirect user back to mfa_redirect_uri after failed mfa setup, but mfa_redirect_uri unchanged" do + @redirect_paths.each do |path| + session[:mfa_redirect_uri] = path + put :update, params: { level: "ui_and_api" } + put :otp_update, params: { otp: "12345", level: "ui_and_api" } + + assert_redirected_to edit_settings_path + assert_equal path, session[:mfa_redirect_uri] + end + end + + should "redirect user back to mfa_redirect_uri after a failed setup + successful setup" do + @redirect_paths.each do |path| + session[:mfa_redirect_uri] = path + put :update, params: { level: "ui_and_api" } + put :otp_update, params: { otp: "12345", level: "ui_and_api" } + + assert_redirected_to edit_settings_path + put :update, params: { level: "ui_and_api" } + put :otp_update, params: { otp: ROTP::TOTP.new(@seed).now, level: "ui_and_api" } + + assert_redirected_to path + assert_nil session[:mfa_redirect_uri] + end end end end diff --git a/test/functional/news_controller_test.rb b/test/functional/news_controller_test.rb index 10c0f79574e..fbe431b4fe5 100644 --- a/test/functional/news_controller_test.rb +++ b/test/functional/news_controller_test.rb @@ -78,7 +78,8 @@ class NewsControllerTest < ActionController::TestCase should "display correct number of entries" do entries = assert_select("h2.gems__gem__name") - assert_equal(entries.size, 2) + + assert_equal(2, entries.size) end end end diff --git a/test/functional/notifiers_controller_test.rb b/test/functional/notifiers_controller_test.rb new file mode 100644 index 00000000000..94fb109e36e --- /dev/null +++ b/test/functional/notifiers_controller_test.rb @@ -0,0 +1,100 @@ +require "test_helper" + +class NotifiersControllerTest < ActionController::TestCase + context "when not logged in" do + setup do + @user = create(:user) + get :show + end + should redirect_to("the sign in page") { sign_in_path } + end + + context "when logged in" do + setup do + @user = create(:user) + sign_in_as(@user) + end + + context "when user owns a gem with more than MFA_REQUIRED_THRESHOLD downloads" do + setup do + @rubygem = create(:rubygem) + @ownership = create(:ownership, rubygem: @rubygem, user: @user) + GemDownload.increment( + Rubygem::MFA_REQUIRED_THRESHOLD + 1, + rubygem_id: @rubygem.id + ) + end + + redirect_scenarios = { + "GET to show" => { action: :show, request: { method: "GET" }, path: "/notifier" }, + "PATCH to update" => { action: :update, request: { method: "PATCH", params: { ownerships: { 1 => { push: "off" } } } }, path: "/notifier" }, + "PUT to update" => { action: :update, request: { method: "PUT", params: { ownerships: { 1 => { push: "off" } } } }, path: "/notifier" } + } + + context "user has mfa disabled" do + redirect_scenarios.each do |label, request_params| + context "on #{label}" do + setup { process(request_params[:action], **request_params[:request]) } + + should redirect_to("the edit settings page") { edit_settings_path } + + should "set mfa_redirect_uri" do + assert_equal request_params[:path], @controller.session[:mfa_redirect_uri] + end + end + end + end + + context "user has mfa set to weak level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + end + + redirect_scenarios.each do |label, request_params| + context "on #{label}" do + setup { process(request_params[:action], **request_params[:request]) } + + should redirect_to("the settings page") { edit_settings_path } + + should "set mfa_redirect_uri" do + assert_equal request_params[:path], @controller.session[:mfa_redirect_uri] + end + end + end + end + + context "user has MFA set to strong level, expect normal behaviour" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + end + + context "on GET to show" do + setup do + get :show + end + + should "stay on notifiers page without redirecting" do + assert_response :success + assert page.has_content? "Email notifications" + end + end + + context "on PATCH to update" do + setup do + patch :update, params: { ownerships: { @ownership.id => { push: "off" } } } + end + + should redirect_to("the notifier page") { notifier_path } + end + + context "on PUT to update" do + setup do + put :update, params: { ownerships: { @ownership.id => { push: "off" } } } + end + + should redirect_to("the notifier page") { notifier_path } + end + end + end + end +end diff --git a/test/functional/oauth_controller_test.rb b/test/functional/oauth_controller_test.rb new file mode 100644 index 00000000000..a00db34fb86 --- /dev/null +++ b/test/functional/oauth_controller_test.rb @@ -0,0 +1,44 @@ +require "test_helper" + +class OAuthControllerTest < ActionController::TestCase + context "on GET to create" do + context "with the wrong provider" do + setup do + get :create, params: { provider: :developer } + end + + should respond_with :not_found + end + + context "without auth info" do + setup do + get :create, params: { provider: :github } + end + + should respond_with :not_found + end + end + + context "on GET to development login" do + context "with valid ID" do + setup do + @admin_user = create(:admin_github_user) + end + + should "login into admin" do + get :development_log_in_as, params: { admin_github_user_id: @admin_user.id } + + assert_response :redirect + assert_redirected_to "/admin" + end + end + + context "with invalid ID" do + should "not login into admin" do + get :development_log_in_as, params: { admin_github_user_id: 0 } + + assert_response :not_found + end + end + end +end diff --git a/test/functional/oidc/api_key_roles_controller_test.rb b/test/functional/oidc/api_key_roles_controller_test.rb new file mode 100644 index 00000000000..9f37a802079 --- /dev/null +++ b/test/functional/oidc/api_key_roles_controller_test.rb @@ -0,0 +1,61 @@ +require "test_helper" + +class OIDC::ApiKeyRolesControllerTest < ActionController::TestCase + context "when not logged in" do + setup { @user = create(:user) } + + context "on GET to index" do + setup { get :index } + + should redirect_to("sign in") { sign_in_path } + end + end + + context "when logged in" do + setup do + @user = create(:user) + @api_key_role = create(:oidc_api_key_role, user: @user) + @id_token = create(:oidc_id_token, api_key_role: @api_key_role) + sign_in_as(@user) + end + + context "with a password session" do + setup do + session[:verification] = 10.minutes.from_now + session[:verified_user] = @user.id + end + + context "on GET to index" do + setup { get :index } + should respond_with :success + end + + context "on GET to show with id" do + setup { get :show, params: { token: @api_key_role.token } } + should respond_with :success + end + + context "on GET to show with nonexistent id" do + setup { get :show, params: { token: "DNE" } } + should respond_with :not_found + end + end + + context "without a password session" do + context "on GET to index" do + setup { get :index } + should redirect_to("verify session") { verify_session_path } + end + + context "on GET to show with id" do + setup { get :show, params: { token: @api_key_role.token } } + should redirect_to("verify session") { verify_session_path } + end + + context "on GET to show with nonexistent id" do + setup { get :show, params: { token: "DNE" } } + should redirect_to("verify session") { verify_session_path } + end + end + end +end diff --git a/test/functional/owners_controller_test.rb b/test/functional/owners_controller_test.rb index dd13d9653fb..7cbc4e78dfd 100644 --- a/test/functional/owners_controller_test.rb +++ b/test/functional/owners_controller_test.rb @@ -5,11 +5,15 @@ class OwnersControllerTest < ActionController::TestCase context "When logged in and verified" do setup do - user = create(:user) + @user = create(:user) @rubygem = create(:rubygem) - create(:ownership, user: user, rubygem: @rubygem) - sign_in_as(user) - session[:verification] = 10.minutes.from_now + create(:ownership, user: @user, rubygem: @rubygem) + verified_sign_in_as(@user) + end + + teardown do + session[:verification] = nil + session[:verified_user] = nil end context "on GET to index" do @@ -31,11 +35,12 @@ class OwnersControllerTest < ActionController::TestCase context "when user does not own the gem" do setup do @other_user = create(:user) - sign_in_as(@other_user) + verified_sign_in_as(@other_user) get :index, params: { rubygem_id: @rubygem.name } end - should respond_with :forbidden + should redirect_to("gem info page") { rubygem_path(@rubygem.slug) } + should set_flash[:alert].to "Forbidden" end end @@ -43,19 +48,20 @@ class OwnersControllerTest < ActionController::TestCase context "when user owns the gem" do context "with invalid handle" do setup do - post :create, params: { handle: "no_user", rubygem_id: @rubygem.name } + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + post :create, params: { handle: "no_user", rubygem_id: @rubygem.name, role: :owner } + end end - should respond_with :unprocessable_entity + should respond_with :unprocessable_content should "show error message" do expected_alert = "User must exist" + assert_equal expected_alert, flash[:alert] end should "not send confirmation email" do - ActionMailer::Base.deliveries.clear - Delayed::Worker.new.work_off assert_emails 0 end end @@ -63,39 +69,106 @@ class OwnersControllerTest < ActionController::TestCase context "with valid handle" do setup do @new_owner = create(:user) - post :create, params: { handle: @new_owner.display_id, rubygem_id: @rubygem.name } + post :create, params: { handle: @new_owner.display_id, rubygem_id: @rubygem.name, role: :owner } end - should redirect_to("ownerships index") { rubygem_owners_path(@rubygem) } + should redirect_to("ownerships index") { rubygem_owners_path(@rubygem.slug) } should "add unconfirmed ownership record" do - assert @rubygem.owners_including_unconfirmed.include?(@new_owner) + assert_includes @rubygem.owners_including_unconfirmed, @new_owner assert_nil @rubygem.ownerships_including_unconfirmed.find_by(user: @new_owner).confirmed_at end should "set success notice flash" do - expected_notice = "#{@new_owner.handle} was added as an unconfirmed owner. "\ - "Ownership access will be enabled after the user clicks on the confirmation mail sent to their email." + expected_notice = "#{@new_owner.handle} was added as an unconfirmed owner. " \ + "Ownership access will be enabled after the user clicks on the confirmation mail sent to their email." + assert_equal expected_notice, flash[:notice] end should "send confirmation email" do - ActionMailer::Base.deliveries.clear - Delayed::Worker.new.work_off + assert_enqueued_emails 1 + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob + assert_emails 1 assert_equal "Please confirm the ownership of #{@rubygem.name} gem on RubyGems.org", last_email.subject assert_equal [@new_owner.email], last_email.to end + + context "when ownership was deleted before running mailer job" do + setup { @rubygem.owners_including_unconfirmed.last.destroy } + + should "not send confirmation email" do + assert_raises(ActiveJob::DeserializationError) do + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob + end + assert_emails 0 + end + end + end + + context "when the gem has mfa requirement" do + setup do + metadata = { "rubygems_mfa_required" => "true" } + create(:version, rubygem: @rubygem, number: "0.1.0", metadata: metadata) + + @new_owner = create(:user) + end + + context "owner has not enabled mfa" do + setup do + post :create, params: { handle: @new_owner.display_id, rubygem_id: @rubygem.name } + end + + should respond_with :forbidden + + should "show error message" do + expected_alert = "The gem has MFA requirement enabled, please setup MFA on your account." + + assert_equal expected_alert, flash[:alert] + end + end + + context "owner has enabled mfa" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + post :create, params: { handle: @new_owner.display_id, rubygem_id: @rubygem.name, role: :owner } + end + + should redirect_to("ownerships index") { rubygem_owners_path(@rubygem.slug) } + + should "set success notice flash" do + expected_notice = "#{@new_owner.handle} was added as an unconfirmed owner. " \ + "Ownership access will be enabled after the user clicks on the confirmation mail sent to their email." + + assert_equal expected_notice, flash[:notice] + end + end + + context "with invalid role" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + post :create, params: { handle: @new_owner.display_id, rubygem_id: @rubygem.name, role: :invalid } + end + + should render_template :index + + should "set alert notice flash" do + assert_equal "Role is not included in the list", flash[:alert] + end + end end end context "when user does not own the gem" do setup do @other_user = create(:user) - sign_in_as(@other_user) + verified_sign_in_as(@other_user) post :create, params: { handle: @other_user.display_id, rubygem_id: @rubygem.name } end - should respond_with :forbidden + should redirect_to("gem info page") { rubygem_path(@rubygem.slug) } + should set_flash[:alert].to "Forbidden" + should "not add other user as owner" do - refute @rubygem.owners_including_unconfirmed.include? @other_user + refute_includes @rubygem.owners_including_unconfirmed, @other_user end end end @@ -113,16 +186,17 @@ class OwnersControllerTest < ActionController::TestCase setup do @second_user = create(:user) @ownership = create(:ownership, rubygem: @rubygem, user: @second_user) - delete :destroy, params: { rubygem_id: @rubygem.name, handle: @second_user.display_id } + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + delete :destroy, params: { rubygem_id: @rubygem.name, handle: @second_user.display_id } + end end - should redirect_to("ownership index") { rubygem_owners_path(@rubygem) } + + should redirect_to("ownership index") { rubygem_owners_path(@rubygem.slug) } + should "remove the ownership record" do - refute @rubygem.owners_including_unconfirmed.include?(@second_user) + refute_includes @rubygem.owners_including_unconfirmed, @second_user end should "send email notifications about owner removal" do - ActionMailer::Base.deliveries.clear - Delayed::Worker.new.work_off - assert_emails 1 assert_contains last_email.subject, "You were removed as an owner from #{@rubygem.name} gem" assert_equal [@second_user.email], last_email.to @@ -133,16 +207,16 @@ class OwnersControllerTest < ActionController::TestCase setup do @second_user = create(:user) @ownership = create(:ownership, :unconfirmed, rubygem: @rubygem, user: @second_user) - delete :destroy, params: { rubygem_id: @rubygem.name, handle: @second_user.display_id } + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + delete :destroy, params: { rubygem_id: @rubygem.name, handle: @second_user.display_id } + end end - should redirect_to("ownership index") { rubygem_owners_path(@rubygem) } + should redirect_to("ownership index") { rubygem_owners_path(@rubygem.slug) } + should "remove the ownership record" do - refute @rubygem.owners_including_unconfirmed.include?(@second_user) + refute_includes @rubygem.owners_including_unconfirmed, @second_user end should "send email notifications about owner removal" do - ActionMailer::Base.deliveries.clear - Delayed::Worker.new.work_off - assert_emails 1 assert_contains last_email.subject, "You were removed as an owner from #{@rubygem.name} gem" assert_equal [@second_user.email], last_email.to @@ -152,19 +226,54 @@ class OwnersControllerTest < ActionController::TestCase context "with handle of last owner" do setup do @last_owner = @rubygem.owners.last - delete :destroy, params: { rubygem_id: @rubygem.name, handle: @last_owner.display_id } + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + delete :destroy, params: { rubygem_id: @rubygem.name, handle: @last_owner.display_id } + end end - should respond_with :forbidden + should set_flash.now[:alert].to "Can't remove the only owner of the gem" + should "not remove the ownership record" do - assert @rubygem.owners_including_unconfirmed.include?(@last_owner) + assert_includes @rubygem.owners_including_unconfirmed, @last_owner + assert_emails 0 end - should "should flash error" do - assert_equal "Can't remove the only owner of the gem", flash[:alert] + end + + context "when the gem has mfa requirement" do + setup do + @second_user = create(:user) + create(:ownership, :unconfirmed, rubygem: @rubygem, user: @second_user) + + metadata = { "rubygems_mfa_required" => "true" } + create(:version, rubygem: @rubygem, number: "0.1.0", metadata: metadata) end - should "not send email notifications about owner removal" do - ActionMailer::Base.deliveries.clear - Delayed::Worker.new.work_off - assert_emails 0 + + context "owner has not enabled mfa" do + setup do + delete :destroy, params: { handle: @second_user.display_id, rubygem_id: @rubygem.name } + end + + should respond_with :forbidden + + should "show error message" do + expected_alert = "The gem has MFA requirement enabled, please setup MFA on your account." + + assert_equal expected_alert, flash[:alert] + end + end + + context "owner has enabled mfa" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + delete :destroy, params: { handle: @second_user.display_id, rubygem_id: @rubygem.name } + end + + should redirect_to("ownerships index") { rubygem_owners_path(@rubygem.slug) } + + should "set success notice flash" do + expected_notice = "#{@second_user.handle} was removed from the owners successfully" + + assert_equal expected_notice, flash[:notice] + end end end end @@ -172,15 +281,16 @@ class OwnersControllerTest < ActionController::TestCase context "when user does not own the gem" do setup do @other_user = create(:user) - sign_in_as(@other_user) + verified_sign_in_as(@other_user) @last_owner = @rubygem.owners.last delete :destroy, params: { rubygem_id: @rubygem.name, handle: @last_owner.display_id } end - should respond_with :forbidden + should redirect_to("gem info page") { rubygem_path(@rubygem.slug) } + should "not remove user as owner" do - assert @rubygem.owners.include? @last_owner + assert_includes @rubygem.owners, @last_owner end end end @@ -188,23 +298,24 @@ class OwnersControllerTest < ActionController::TestCase context "on GET to resend confirmation" do setup do @new_owner = create(:user) - sign_in_as(@new_owner) + verified_sign_in_as(@new_owner) end context "when unconfirmed ownership exists" do setup do create(:ownership, :unconfirmed, rubygem: @rubygem, user: @new_owner) - get :resend_confirmation, params: { rubygem_id: @rubygem.name } + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + get :resend_confirmation, params: { rubygem_id: @rubygem.name } + end end - should redirect_to("rubygem show") { rubygem_path(@rubygem) } + should redirect_to("rubygem show") { rubygem_path(@rubygem.slug) } should "set success notice flash" do success_flash = "A confirmation mail has been re-sent to your email" + assert_equal success_flash, flash[:notice] end should "resend confirmation email" do - ActionMailer::Base.deliveries.clear - Delayed::Worker.new.work_off assert_emails 1 assert_equal "Please confirm the ownership of #{@rubygem.name} gem on RubyGems.org", last_email.subject assert_equal [@new_owner.email], last_email.to @@ -213,13 +324,14 @@ class OwnersControllerTest < ActionController::TestCase context "when ownership doesn't exist" do setup do - get :resend_confirmation, params: { rubygem_id: @rubygem.name } + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + get :resend_confirmation, params: { rubygem_id: @rubygem.name } + end end should respond_with :not_found + should "not resend confirmation email" do - ActionMailer::Base.deliveries.clear - Delayed::Worker.new.work_off assert_emails 0 end end @@ -227,17 +339,125 @@ class OwnersControllerTest < ActionController::TestCase context "when confirmed ownership exists" do setup do create(:ownership, rubygem: @rubygem, user: @new_owner) - get :resend_confirmation, params: { rubygem_id: @rubygem.name } + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + get :resend_confirmation, params: { rubygem_id: @rubygem.name } + end end should respond_with :not_found + should "not resend confirmation email" do - ActionMailer::Base.deliveries.clear - Delayed::Worker.new.work_off assert_emails 0 end end end + + context "on GET edit ownership" do + setup do + @owner = create(:user) + @maintainer = create(:user) + @rubygem = create(:rubygem, owners: [@owner, @maintainer]) + + verified_sign_in_as(@owner) + end + + context "when editing another owner's role" do + setup do + get :edit, params: { rubygem_id: @rubygem.name, handle: @maintainer.display_id } + end + + should respond_with :success + should render_template :edit + end + + context "when editing your own role" do + setup do + get :edit, params: { rubygem_id: @rubygem.name, handle: @owner.display_id } + end + + should redirect_to("gem info page") { rubygem_path(@rubygem.slug) } + should set_flash[:alert].to "Can't update your own Role" + end + end + + context "on PATCH to update ownership" do + setup do + @owner = create(:user) + @maintainer = create(:user) + @rubygem = create(:rubygem, owners: [@owner, @maintainer]) + + verified_sign_in_as(@owner) + patch :update, params: { rubygem_id: @rubygem.name, handle: @maintainer.display_id, role: :maintainer } + end + + should redirect_to("rubygem show") { rubygem_owners_path(@rubygem.slug) } + + should "set success notice flash" do + assert_equal "#{@maintainer.name} was succesfully updated.", flash[:notice] + end + + should "downgrade the ownership to a maintainer role" do + ownership = Ownership.find_by(rubygem: @rubygem, user: @maintainer) + + assert_predicate ownership, :maintainer? + assert_enqueued_email_with OwnersMailer, :owner_updated, params: { ownership: ownership, authorizer: @owner } + end + end + + context "when updating ownership without role" do + setup do + @owner = create(:user) + @maintainer = create(:user) + @rubygem = create(:rubygem, owners: [@owner, @maintainer]) + + verified_sign_in_as(@owner) + patch :update, params: { rubygem_id: @rubygem.name, handle: @maintainer.display_id } + end + + should redirect_to("ownerships index") { rubygem_owners_path(@rubygem.slug) } + + should "not update the role" do + ownership = Ownership.find_by(rubygem: @rubygem, user: @maintainer) + + assert_predicate ownership, :owner? + end + end + + context "when updating ownership with invalid role" do + setup do + @owner = create(:user) + @maintainer = create(:user) + @rubygem = create(:rubygem, owners: [@owner, @maintainer]) + + verified_sign_in_as(@owner) + patch :update, params: { rubygem_id: @rubygem.name, handle: @maintainer.display_id, role: :invalid } + end + + should respond_with :unprocessable_content + + should "set error flash message" do + assert_equal "Role is not included in the list", flash[:alert] + end + end + + context "when updating the role of currently signed in user" do + setup do + @owner = create(:user) + @rubygem = create(:rubygem) + @ownership = create(:ownership, user: @owner, rubygem: @rubygem, role: :owner) + + verified_sign_in_as(@owner) + patch :update, params: { rubygem_id: @rubygem.name, handle: @owner.display_id, role: :maintainer } + end + + should "not update the ownership of the current user" do + assert_predicate @ownership.reload, :owner? + end + + should "set notice flash message" do + assert_equal "Can't update your own Role", flash[:alert] + end + end end context "when logged in and unverified" do @@ -260,13 +480,14 @@ class OwnersControllerTest < ActionController::TestCase context "on POST to create ownership" do setup do @new_owner = create(:user) - post :create, params: { handle: @new_owner.display_id, rubygem_id: @rubygem.name } + post :create, params: { handle: @new_owner.display_id, rubygem_id: @rubygem.name, role: :owner } end should redirect_to("sessions#verify") { verify_session_path } should use_before_action(:redirect_to_verify) + should "not add unconfirmed ownership record" do - refute @rubygem.owners_including_unconfirmed.include?(@new_owner) + refute_includes @rubygem.owners_including_unconfirmed, @new_owner end end @@ -278,10 +499,33 @@ class OwnersControllerTest < ActionController::TestCase end should redirect_to("sessions#verify") { verify_session_path } should use_before_action(:redirect_to_verify) - should "remove the ownership record" do - assert @rubygem.owners_including_unconfirmed.include?(@second_user) + + should "not remove the ownership record" do + assert_includes @rubygem.owners, @second_user end end + + context "on GET to edit" do + setup do + @second_user = create(:user) + @ownership = create(:ownership, :unconfirmed, rubygem: @rubygem, user: @second_user) + get :edit, params: { rubygem_id: @rubygem.name, handle: @second_user.display_id } + end + + should redirect_to("sessions#verify") { verify_session_path } + should use_before_action(:redirect_to_verify) + end + + context "on PATCH to update" do + setup do + @second_user = create(:user) + @ownership = create(:ownership, :unconfirmed, rubygem: @rubygem, user: @second_user, role: :owner) + patch :update, params: { rubygem_id: @rubygem.name, handle: @second_user.display_id, role: :maintainer } + end + + should redirect_to("sessions#verify") { verify_session_path } + should use_before_action(:redirect_to_verify) + end end context "When user not logged in" do @@ -298,48 +542,49 @@ class OwnersControllerTest < ActionController::TestCase context "when token has not expired" do setup do - get :confirm, params: { rubygem_id: @rubygem.name, token: @ownership.token } + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + get :confirm, params: { rubygem_id: @rubygem.name, token: @ownership.token } + end @ownership.reload end should "confirm ownership" do - assert @ownership.confirmed? - assert redirect_to("rubygem show") { rubygem_path(@rubygem) } + assert_predicate @ownership, :confirmed? + assert redirect_to("rubygem show") { rubygem_path(@rubygem.slug) } assert_equal "You were added as an owner to #{@rubygem.name} gem", flash[:notice] end should "not sign in the user" do - refute @controller.request.env[:clearance].signed_in? + refute_predicate @controller.request.env[:clearance], :signed_in? end should "send email notifications about new owner" do - ActionMailer::Base.deliveries.clear - Delayed::Worker.new.work_off - owner_added_email_subjects = ActionMailer::Base.deliveries.map(&:subject) + assert_contains owner_added_email_subjects, "You were added as an owner to #{@rubygem.name} gem" assert_contains owner_added_email_subjects, "User #{@user.handle} was added as an owner to #{@rubygem.name} gem" owner_added_email_to = ActionMailer::Base.deliveries.map(&:to).flatten + assert_same_elements @rubygem.owners.map(&:email), owner_added_email_to end end context "when token has expired" do setup do - travel_to Time.current + 3.days - get :confirm, params: { rubygem_id: @rubygem.name, token: @ownership.token } + travel_to 3.days.from_now + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + get :confirm, params: { rubygem_id: @rubygem.name, token: @ownership.token } + end end should "warn about invalid token" do assert respond_with :success assert_equal "The confirmation token has expired. Please try resending the token from the gem page.", flash[:alert] - assert @ownership.unconfirmed? + assert_predicate @ownership, :unconfirmed? end should "not send email notification about owner added" do - ActionMailer::Base.deliveries.clear - Delayed::Worker.new.work_off assert_emails 0 end end @@ -376,5 +621,27 @@ class OwnersControllerTest < ActionController::TestCase assert redirect_to("sign in") { sign_in_path } end end + + context "on EDIT to update owner" do + setup do + create(:ownership, rubygem: @rubygem, user: @user) + get :edit, params: { rubygem_id: @rubygem.name, handle: @user.display_id } + end + + should "redirect to sign in path" do + assert redirect_to("sign in") { sign_in_path } + end + end + + context "on PATCH to update owner" do + setup do + create(:ownership, rubygem: @rubygem, user: @user) + patch :update, params: { rubygem_id: @rubygem.name, handle: @user.display_id, role: :owner } + end + + should "redirect to sign in path" do + assert redirect_to("sign in") { sign_in_path } + end + end end end diff --git a/test/functional/ownership_calls_controller_test.rb b/test/functional/ownership_calls_controller_test.rb new file mode 100644 index 00000000000..e53d1e3e5d1 --- /dev/null +++ b/test/functional/ownership_calls_controller_test.rb @@ -0,0 +1,373 @@ +require "test_helper" + +class OwnershipCallsControllerTest < ActionController::TestCase + context "When logged in" do + setup do + @user = create(:user) + sign_in_as(@user) + end + + teardown do + sign_out + end + + context "on POST to create" do + setup do + @rubygem = create(:rubygem, owners: [@user], number: "1.0.0") + end + + context "user is owner of rubygem and verified" do + setup do + session[:verification] = 10.minutes.from_now + session[:verified_user] = @user.id + end + + teardown do + session[:verification] = nil + session[:verified_user] = nil + end + + context "with correct params" do + setup do + post :create, params: { rubygem_id: @rubygem.name, note: "short note" } + end + should redirect_to("adoptions index") { rubygem_adoptions_path(@rubygem.slug) } + should "set success notice flash" do + expected_notice = "Created ownership call for #{@rubygem.name}." + + assert_equal expected_notice, flash[:notice] + end + should "create a call" do + assert_not_nil @rubygem.ownership_calls.find_by(user: @user) + end + end + + context "with params missing" do + setup do + post :create, params: { rubygem_id: @rubygem.name } + end + should redirect_to("adoptions index") { rubygem_adoptions_path(@rubygem.slug) } + should "set alert flash" do + expected_alert = "Note can't be blank" + + assert_equal expected_alert, flash[:alert] + end + should "not create a call" do + assert_nil @rubygem.ownership_calls.find_by(user: @user) + end + end + + context "when call is already open" do + setup do + create(:ownership_call, rubygem: @rubygem) + post :create, params: { rubygem_id: @rubygem.name, note: "other small note" } + end + should redirect_to("adoptions index") { rubygem_adoptions_path(@rubygem.slug) } + should "set alert flash" do + expected_alert = "Rubygem can have only one open ownership call" + + assert_equal expected_alert, flash[:alert] + end + should "not create a call" do + assert_equal 1, @rubygem.ownership_calls.count + end + end + end + + context "user is owner and not verified" do + setup do + post :create, params: { rubygem_id: @rubygem.name, note: "short note" } + end + + should redirect_to("verify page") { verify_session_path } + end + + context "user is not owner of rubygem" do + setup do + @user = create(:user) + sign_in_as(@user) + session[:verification] = 10.minutes.from_now + session[:verified_user] = @user.id + post :create, params: { rubygem_id: @rubygem.name, note: "short note" } + end + + teardown do + session[:verification] = nil + session[:verified_user] = nil + end + + should respond_with :forbidden + + should "not create a call" do + assert_nil @rubygem.ownership_calls.find_by(user: @user) + end + end + end + + context "on PATCH to close" do + setup do + @rubygem = create(:rubygem, owners: [@user], number: "1.0.0") + end + + context "user is owner of rubygem and verified" do + setup do + session[:verification] = 10.minutes.from_now + session[:verified_user] = @user.id + end + + teardown do + session[:verification] = nil + session[:verified_user] = nil + end + + context "ownership call exists" do + setup do + create(:ownership_call, rubygem: @rubygem, user: @user, status: "opened") + patch :close, params: { rubygem_id: @rubygem.name } + end + should redirect_to("rubygems show") { rubygem_path(@rubygem.slug) } + should "set success notice flash" do + expected_notice = "The ownership call for #{@rubygem.name} was closed." + + assert_equal expected_notice, flash[:notice] + end + should "update status to close" do + assert_empty @rubygem.ownership_calls + end + end + + context "ownership call does not exist" do + setup do + patch :close, params: { rubygem_id: @rubygem.name } + end + should redirect_to("adoptions index") { rubygem_adoptions_path(@rubygem.slug) } + + should "set try again notice flash" do + assert_equal "Something went wrong. Please try again.", flash[:alert] + end + end + end + + context "user is owner and not verified" do + setup do + create(:ownership_call, rubygem: @rubygem, user: @user) + patch :close, params: { rubygem_id: @rubygem.name } + end + + should redirect_to("verify page") { verify_session_path } + end + + context "user is not owner of rubygem" do + setup do + @user = create(:user) + sign_in_as(@user) + session[:verification] = 10.minutes.from_now + session[:verified_user] = @user.id + + create(:ownership_call, rubygem: @rubygem, user: @user) + patch :close, params: { rubygem_id: @rubygem.name } + end + + teardown do + session[:verification] = nil + session[:verified_user] = nil + end + + should respond_with :forbidden + + should "not update status to close" do + assert_not_empty @rubygem.ownership_calls + end + end + end + + context "when user owns a gem with more than MFA_REQUIRED_THRESHOLD downloads" do + setup do + @rubygem = create(:rubygem) + create(:ownership, rubygem: @rubygem, user: @user) + GemDownload.increment( + Rubygem::MFA_REQUIRED_THRESHOLD + 1, + rubygem_id: @rubygem.id + ) + end + + context "user has mfa disabled" do + context "on GET to index" do + setup do + get :index, params: { rubygem_id: @rubygem.name } + end + should respond_with :success + + should "not redirect to mfa" do + assert page.has_content? "Maintainers wanted" + end + end + + context "on PATCH to close" do + setup do + patch :close, params: { rubygem_id: @rubygem.name } + end + should redirect_to("the edit settings page") { edit_settings_path } + + should "set mfa_redirect_uri" do + assert_equal close_rubygem_ownership_calls_path, session[:mfa_redirect_uri] + end + end + + context "on POST to create" do + setup do + post :create, params: { rubygem_id: @rubygem.name, note: "short note" } + end + should redirect_to("the edit settings page") { edit_settings_path } + + should "set mfa_redirect_uri" do + assert_equal rubygem_ownership_calls_path, session[:mfa_redirect_uri] + end + end + end + + context "user has mfa set to weak level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + end + + context "on GET to index" do + setup do + get :index, params: { rubygem_id: @rubygem.name } + end + should respond_with :success + + should "not redirect to mfa" do + assert page.has_content? "Maintainers wanted" + end + end + + context "on PATCH to close" do + setup do + patch :close, params: { rubygem_id: @rubygem.name } + end + should redirect_to("edit settings page") { edit_settings_path } + + should "set mfa_redirect_uri" do + assert_equal close_rubygem_ownership_calls_path, session[:mfa_redirect_uri] + end + end + + context "on POST to create" do + setup do + post :create, params: { rubygem_id: @rubygem.name, note: "short note" } + end + should redirect_to("edit settings page") { edit_settings_path } + + should "set mfa_redirect_uri" do + assert_equal rubygem_ownership_calls_path, session[:mfa_redirect_uri] + end + end + end + + context "user has MFA set to strong level, expect normal behaviour" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + session[:verification] = 10.minutes.from_now + session[:verified_user] = @user.id + end + + teardown do + session[:verification] = nil + session[:verified_user] = nil + end + + context "on GET to index" do + setup do + get :index, params: { rubygem_id: @rubygem.name } + end + should respond_with :success + + should "not redirect to mfa" do + assert page.has_content? "Maintainers wanted" + end + end + + context "on PATCH to close" do + setup do + create(:ownership_call, rubygem: @rubygem, user: @user, status: "opened") + patch :close, params: { rubygem_id: @rubygem.name } + end + should redirect_to("rubygems show") { rubygem_path(@rubygem.slug) } + end + + context "on POST to create" do + setup do + post :create, params: { rubygem_id: @rubygem.name, note: "short note" } + end + should redirect_to("adoptions index") { rubygem_adoptions_path(@rubygem.slug) } + end + end + end + end + + context "When user not logged in" do + context "on POST to create" do + setup do + @rubygem = create(:rubygem, number: "1.0.0") + post :create, params: { rubygem_id: @rubygem.name, note: "short note" } + end + + should "redirect to sign in" do + assert_redirected_to sign_in_path + end + should "not create call" do + assert_empty @rubygem.ownership_calls + end + end + + context "on PATCH to close" do + setup do + @rubygem = create(:rubygem, number: "1.0.0") + create(:ownership_call, rubygem: @rubygem) + patch :close, params: { rubygem_id: @rubygem.name } + end + + should "redirect to sign in" do + assert_redirected_to sign_in_path + end + should "not close the call" do + assert_not_empty @rubygem.ownership_calls + end + end + + context "on GET to index" do + setup do + rubygems = create_list(:rubygem, 3, number: "1.0.0") + @ownership_calls = [] + rubygems.each do |rubygem| + @ownership_calls << create(:ownership_call, rubygem: rubygem) + end + get :index + end + should respond_with :success + should "not include closed calls" do + ownership_call = create(:ownership_call, :closed) + + refute page.has_content? ownership_call.rubygem_name + end + should "order calls by created date" do + expected_order = @ownership_calls.reverse.map(&:rubygem_name) + actual_order = assert_select("a.gems__gem__name").map(&:text) + + expected_order.each_with_index do |expected_gem_name, i| + assert_match(/#{expected_gem_name}/, actual_order[i]) + end + end + + should "display entries and total in page info" do + assert_select "header > p.gems__meter", text: /Displaying all 3 ownership calls/ + end + should "display correct number of entries" do + entries = assert_select("a.gems__gem__name") + + assert_equal 3, entries.size + end + end + end +end diff --git a/test/functional/ownership_requests_controller_test.rb b/test/functional/ownership_requests_controller_test.rb new file mode 100644 index 00000000000..2a29093de1d --- /dev/null +++ b/test/functional/ownership_requests_controller_test.rb @@ -0,0 +1,480 @@ +require "test_helper" + +class OwnershipRequestsControllerTest < ActionController::TestCase + include ActionMailer::TestHelper + + context "when logged in" do + setup do + @user = create(:user) + sign_in_as(@user) + end + + context "on POST to create" do + context "for popular gem" do + setup do + @rubygem = create(:rubygem, downloads: 2_000_000) + create(:version, rubygem: @rubygem, created_at: 2.years.ago, number: "1.0.0") + end + context "when user is owner" do + setup do + create(:ownership, user: @user, rubygem: @rubygem) + post :create, params: { rubygem_id: @rubygem.name, note: "small note" } + end + + should respond_with :forbidden + + should "not create ownership request" do + assert_nil @rubygem.ownership_requests.find_by(user: @user) + end + end + + context "when user is not an owner" do + context "ownership call exists" do + setup do + create(:ownership_call, rubygem: @rubygem) + post :create, params: { rubygem_id: @rubygem.name, note: "small note" } + end + should redirect_to("adoptions index") { rubygem_adoptions_path(@rubygem.slug) } + + should "create ownership request" do + assert_not_nil @rubygem.ownership_requests.find_by(user: @user) + end + end + + context "ownership call doesn't exist" do + setup do + post :create, params: { rubygem_id: @rubygem.name, note: "small note" } + end + should respond_with :forbidden + + should "not create ownership request" do + assert_nil @rubygem.ownership_requests.find_by(user: @user) + end + end + end + end + + context "for less popular gem" do + setup do + @rubygem = create(:rubygem, downloads: 2_000) + create(:version, rubygem: @rubygem, created_at: 2.years.ago, number: "1.0.0") + end + + context "when user is owner" do + setup do + create(:ownership, user: @user, rubygem: @rubygem) + post :create, params: { rubygem_id: @rubygem.name, note: "small note" } + end + + should redirect_to("adoptions index") { rubygem_adoptions_path(@rubygem.slug) } + should set_flash[:alert].to("User is already an owner") + + should "not create ownership call" do + assert_nil @rubygem.ownership_requests.find_by(user: @user) + end + end + + context "when user is not an owner" do + context "with correct params" do + setup do + post :create, params: { rubygem_id: @rubygem.name, note: "small note" } + end + should redirect_to("adoptions index") { rubygem_adoptions_path(@rubygem.slug) } + should set_flash[:notice].to("Your ownership request was submitted.") + + should "create ownership request" do + assert_not_nil @rubygem.ownership_requests.find_by(user: @user) + end + end + context "with missing params" do + setup do + post :create, params: { rubygem_id: @rubygem.name } + end + should redirect_to("adoptions index") { rubygem_adoptions_path(@rubygem.slug) } + should set_flash[:alert].to("Note can't be blank") + + should "not create ownership call" do + assert_nil @rubygem.ownership_requests.find_by(user: @user) + end + end + context "when request from user exists" do + setup do + create(:ownership_request, rubygem: @rubygem, user: @user, note: "other note") + post :create, params: { rubygem_id: @rubygem.name, note: "new note" } + end + should redirect_to("adoptions index") { rubygem_adoptions_path(@rubygem.slug) } + should set_flash[:alert].to("User has already requested ownership") + end + end + end + end + + context "on PATCH to update" do + setup do + @rubygem = create(:rubygem, downloads: 2_000_000) + create(:version, rubygem: @rubygem, created_at: 2.years.ago, number: "1.0.0") + end + context "when user is owner and verified" do + setup do + create(:ownership, user: @user, rubygem: @rubygem) + session[:verification] = 10.minutes.from_now + session[:verified_user] = @user.id + end + + teardown do + session[:verification] = nil + session[:verified_user] = nil + end + + context "on close" do + setup do + @requester = create(:user) + ownership_request = create(:ownership_request, rubygem: @rubygem, user: @requester) + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + patch :update, params: { rubygem_id: @rubygem.name, id: ownership_request.id, status: "close" } + end + end + should redirect_to("adoptions index") { rubygem_adoptions_path(@rubygem.slug) } + should "set success notice flash" do + expected_notice = "Ownership request was closed." + + assert_equal expected_notice, flash[:notice] + end + should "send email notifications" do + assert_emails 1 + assert_equal "Your ownership request was closed.", last_email.subject + assert_equal [@requester.email], last_email.to + end + end + + context "on approve" do + setup do + @requester = create(:user) + ownership_request = create(:ownership_request, rubygem: @rubygem, user: @requester) + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + patch :update, params: { rubygem_id: @rubygem.name, id: ownership_request.id, status: "approve" } + end + end + should redirect_to("adoptions index") { rubygem_adoptions_path(@rubygem.slug) } + should "set success notice flash" do + expected_notice = "Ownership request was approved. #{@user.display_id} is added as an owner." + + assert_equal expected_notice, flash[:notice] + end + should "add ownership record" do + ownership = Ownership.find_by(rubygem: @rubygem, user: @requester) + + refute_nil ownership + assert_predicate ownership, :confirmed? + end + should "send email notification" do + assert_emails 3 + request_approved_subjects = ActionMailer::Base.deliveries.map(&:subject) + + assert_contains request_approved_subjects, "Your ownership request was approved." + assert_contains request_approved_subjects, "User #{@requester.handle} was added as an owner to #{@rubygem.name} gem" + + owner_removed_email_to = ActionMailer::Base.deliveries.map(&:to).flatten.uniq + + assert_same_elements @rubygem.owners.pluck(:email), owner_removed_email_to + end + end + + context "on incorrect status" do + setup do + @requester = create(:user) + request = create(:ownership_request, rubygem: @rubygem, user: @requester) + patch :update, params: { rubygem_id: @rubygem.name, id: request.id, status: "random" } + end + + should redirect_to("adoptions index") { rubygem_adoptions_path(@rubygem.slug) } + + should "set try again flash" do + assert_equal "Something went wrong. Please try again.", flash[:alert] + end + end + end + + context "when user is owner and not verified" do + setup do + create(:ownership, user: @user, rubygem: @rubygem) + @requester = create(:user) + ownership_request = create(:ownership_request, rubygem: @rubygem, user: @requester) + patch :update, params: { rubygem_id: @rubygem.name, id: ownership_request.id, status: "close" } + end + should redirect_to("verify page") { verify_session_path } + end + + context "when user is not an owner" do + setup do + request = create(:ownership_request, rubygem: @rubygem) + session[:verification] = 10.minutes.from_now + session[:verified_user] = @user.id + patch :update, params: { rubygem_id: @rubygem.name, id: request.id, status: "close" } + end + + teardown do + session[:verification] = nil + session[:verified_user] = nil + end + + should respond_with :forbidden + end + end + + context "on PATCH to close_all" do + setup do + @rubygem = create(:rubygem, downloads: 2_000_000) + create(:version, rubygem: @rubygem, created_at: 2.years.ago, number: "1.0.0") + end + context "when user is owner and verified" do + setup do + create(:ownership, rubygem: @rubygem, user: @user) + create_list(:ownership_request, 3, rubygem: @rubygem) + session[:verification] = 10.minutes.from_now + session[:verified_user] = @user.id + end + + teardown do + session[:verification] = nil + session[:verified_user] = nil + end + + context "with successful update" do + setup do + patch :close_all, params: { rubygem_id: @rubygem.name } + end + should redirect_to("adoptions index") { rubygem_adoptions_path(@rubygem.slug) } + should "set success notice flash" do + expected_notice = "All open ownership requests for #{@rubygem.name} were closed." + + assert_equal expected_notice, flash[:notice] + end + should "close all open requests" do + assert_empty @rubygem.ownership_requests + end + end + + context "with unsuccessful update" do + setup do + OwnershipRequest.any_instance.stubs(:update!).raises(ActiveRecord::RecordNotSaved) + patch :close_all, params: { rubygem_id: @rubygem.name } + end + + should redirect_to("adoptions index") { rubygem_adoptions_path(@rubygem.slug) } + should "set success notice flash" do + expected_notice = "Something went wrong. Please try again." + + assert_equal expected_notice, flash[:alert] + end + end + end + + context "when user is owner and not verified" do + setup do + create(:ownership, rubygem: @rubygem, user: @user) + patch :close_all, params: { rubygem_id: @rubygem.name } + end + should redirect_to("verify page") { verify_session_path } + end + + context "user is not owner" do + setup do + create_list(:ownership_request, 3, rubygem: @rubygem) + patch :close_all, params: { rubygem_id: @rubygem.name } + end + + should respond_with :forbidden + + should "not close all open requests" do + assert_equal 3, @rubygem.ownership_requests.count + end + end + end + + context "when user owns a gem with more than MFA_REQUIRED_THRESHOLD downloads" do + setup do + @mfa_rubygem = create(:rubygem) + create(:ownership, rubygem: @mfa_rubygem, user: @user) + GemDownload.increment( + Rubygem::MFA_REQUIRED_THRESHOLD + 1, + rubygem_id: @mfa_rubygem.id + ) + @rubygem = create(:rubygem) + create(:ownership_call, rubygem: @rubygem) + @ownership_request = create(:ownership_request) + end + + context "user has mfa disabled" do + context "POST to create" do + setup { post :create, params: { rubygem_id: @rubygem.name, note: "small note" } } + + should redirect_to("the edit settings page") { edit_settings_path } + + should "set mfa_redirect_uri" do + assert_equal rubygem_ownership_requests_path, session[:mfa_redirect_uri] + end + end + + context "PATCH to close_all" do + setup { patch :close_all, params: { rubygem_id: @rubygem.name } } + + should redirect_to("the edit settings page") { edit_settings_path } + + should "set mfa_redirect_uri" do + assert_equal close_all_rubygem_ownership_requests_path, session[:mfa_redirect_uri] + end + end + + context "PATCH to update" do + setup { patch :update, params: { rubygem_id: @rubygem.name, id: @ownership_request.id, status: "closed" } } + + should redirect_to("the edit settings page") { edit_settings_path } + + should "set mfa_redirect_uri" do + assert_equal rubygem_ownership_request_path, session[:mfa_redirect_uri] + end + end + + context "PUT to update" do + setup { put :update, params: { rubygem_id: @rubygem.name, id: @ownership_request.id, status: "closed" } } + + should redirect_to("the edit settings page") { edit_settings_path } + + should "set mfa_redirect_uri" do + assert_equal rubygem_ownership_request_path, session[:mfa_redirect_uri] + end + end + end + + context "user has mfa set to weak level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + end + + context "POST to create" do + setup { post :create, params: { rubygem_id: @rubygem.name, note: "small note" } } + + should redirect_to("the edit settings page") { edit_settings_path } + + should "set mfa_redirect_uri" do + assert_equal rubygem_ownership_requests_path, session[:mfa_redirect_uri] + end + end + + context "PATCH to close_all" do + setup do + patch :close_all, params: { rubygem_id: @rubygem.name } + end + + should redirect_to("the edit settings page") { edit_settings_path } + + should "set mfa_redirect_uri" do + assert_equal close_all_rubygem_ownership_requests_path, session[:mfa_redirect_uri] + end + end + + context "PATCH to update" do + setup { patch :update, params: { rubygem_id: @rubygem.name, id: @ownership_request.id, status: "closed" } } + + should redirect_to("the edit settings page") { edit_settings_path } + + should "set mfa_redirect_uri" do + assert_equal rubygem_ownership_request_path, session[:mfa_redirect_uri] + end + end + + context "PUT to update" do + setup { put :update, params: { rubygem_id: @rubygem.name, id: @ownership_request.id, status: "closed" } } + + should redirect_to("the edit settings page") { edit_settings_path } + + should "set mfa_redirect_uri" do + assert_equal rubygem_ownership_request_path, session[:mfa_redirect_uri] + end + end + end + + context "user has MFA set to strong level, expect normal behaviour" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + session[:verification] = 10.minutes.from_now + session[:verified_user] = @user.id + end + + teardown do + session[:verification] = nil + session[:verified_user] = nil + end + + context "POST to create" do + setup { post :create, params: { rubygem_id: @rubygem.name, note: "small note" } } + + should redirect_to("adoptions index") { rubygem_adoptions_path(@rubygem.slug) } + end + + context "PATCH to close_all" do + setup do + create(:version, rubygem: @rubygem, created_at: 2.years.ago, number: "1.0.0") + create(:ownership, rubygem: @rubygem, user: @user) + create_list(:ownership_request, 3, rubygem: @rubygem) + + patch :close_all, params: { rubygem_id: @rubygem.name } + end + + should redirect_to("adoptions index") { rubygem_adoptions_path(@rubygem.slug) } + end + + context "PATCH to update" do + setup do + @requester = create(:user) + create(:ownership_request, rubygem: @rubygem, user: @requester) + patch :update, params: { rubygem_id: @rubygem.name, id: @ownership_request.id, status: "closed" } + end + + should redirect_to("adoptions index") { rubygem_adoptions_path(@rubygem.slug) } + end + + context "PUT to update" do + setup do + @requester = create(:user) + create(:ownership_request, rubygem: @rubygem, user: @requester) + put :update, params: { rubygem_id: @rubygem.name, id: @ownership_request.id, status: "closed" } + end + + should redirect_to("adoptions index") { rubygem_adoptions_path(@rubygem.slug) } + end + end + end + end + + context "when not logged in" do + setup do + @rubygem = create(:rubygem, downloads: 2_000) + create(:version, rubygem: @rubygem, created_at: 2.years.ago, number: "1.0.0") + end + + context "on POST to create" do + setup do + post :create, params: { rubygem_id: @rubygem.name, note: "small note" } + end + should redirect_to("sign in") { sign_in_path } + end + + context "on PATCH to update" do + setup do + ownership_request = create(:ownership_request) + patch :update, params: { rubygem_id: ownership_request.rubygem_name, id: ownership_request.id, status: "closed" } + end + should redirect_to("sign in") { sign_in_path } + end + + context "on PATCH to close_all" do + setup do + create_list(:ownership_request, 3, rubygem: @rubygem) + patch :close_all, params: { rubygem_id: @rubygem.name } + end + should redirect_to("sign in") { sign_in_path } + end + end +end diff --git a/test/functional/pages_controller_test.rb b/test/functional/pages_controller_test.rb new file mode 100644 index 00000000000..2043029f4f0 --- /dev/null +++ b/test/functional/pages_controller_test.rb @@ -0,0 +1,19 @@ +require "test_helper" + +class PagesControllerTest < ActionController::TestCase + context "when valid page is requested" do + setup do + get :show, params: { id: "about" } + end + + should respond_with :ok + end + + context "when invalid page is requested" do + should "error" do + assert_raises(ActionController::UrlGenerationError) do + get :show, params: { id: "not-found-page" } + end + end + end +end diff --git a/test/functional/passwords_controller_test.rb b/test/functional/passwords_controller_test.rb index 0dd90a8d3c3..9e7ae33ac0f 100644 --- a/test/functional/passwords_controller_test.rb +++ b/test/functional/passwords_controller_test.rb @@ -5,7 +5,8 @@ class PasswordsControllerTest < ActionController::TestCase context "when missing email" do should "alerts about missing email" do post :create - assert_equal flash[:alert], "Email can't be blank." + + assert_equal "Email can't be blank.", flash[:alert] end end @@ -16,7 +17,7 @@ class PasswordsControllerTest < ActionController::TestCase end should "set a valid confirmation_token" do - assert @user.valid_confirmation_token? + assert_predicate @user, :valid_confirmation_token? end end end @@ -27,151 +28,582 @@ class PasswordsControllerTest < ActionController::TestCase @user.forgot_password! end - context "with valid confirmation_token" do + context "with incorrect token" do setup do - get :edit, params: { token: @user.confirmation_token, user_id: @user.id } + get :edit, params: { token: "invalidtoken" } end - should respond_with :success - should "display edit form" do - assert page.has_content?("Reset password") + should redirect_to("the sign in page") { sign_in_path } + should set_flash[:alert].to "Please double check the URL or try submitting a new password reset." + + should "not sign in the user" do + refute_predicate @controller.request.env[:clearance], :signed_in? + end + end + + context "with valid confirmation_token" do + context "when not signed in" do + setup do + get :edit, params: { token: @user.confirmation_token } + end + + should respond_with :success + + should "not sign in the user" do + refute_predicate @controller.request.env[:clearance], :signed_in? + end + + should "invalidate the confirmation_token" do + assert_nil @user.reload.confirmation_token + end + + should "display edit form" do + assert_text "Reset password" + assert_selector "input[type=password][autocomplete=new-password]" + end + + should "instruct the browser not to send referrer that contains the token" do + assert_equal "no-referrer", response.headers["Referrer-Policy"] + end + end + + context "when signed in as the user" do + setup do + sign_in_as @user + + get :edit, params: { token: @user.confirmation_token } + end + + should respond_with :success + + should "leave the user signed in" do + assert_predicate @controller.request.env[:clearance], :signed_in? + end + + should "invalidate the confirmation_token" do + assert_nil @user.reload.confirmation_token + end + + should "display edit form" do + assert_text "Reset password" + assert_selector "input[type=password][autocomplete=new-password]" + end + + should "instruct the browser not to send referrer that contains the token" do + assert_equal "no-referrer", response.headers["Referrer-Policy"] + end + end + + context "when signed in as another user" do + setup do + @other_user = create(:user, api_key: "otheruserkey") + sign_in_as @other_user + + get :edit, params: { token: @user.confirmation_token } + end + + should respond_with :success + + should "sign the current user out" do + refute_predicate @controller.request.env[:clearance], :signed_in? + end + + should "invalidate the confirmation_token" do + assert_nil @user.reload.confirmation_token + end + + should "display edit form" do + assert_text "Reset password" + assert_selector "input[type=password][autocomplete=new-password]" + end + + should "instruct the browser not to send referrer that contains the token" do + assert_equal "no-referrer", response.headers["Referrer-Policy"] + end end end context "with expired confirmation_token" do setup do @user.update_attribute(:token_expires_at, 1.minute.ago) - get :edit, params: { token: @user.confirmation_token, user_id: @user.id } + get :edit, params: { token: @user.confirmation_token } end - should redirect_to("the home page") { root_path } - should "warn about invalid url" do - assert_equal flash[:alert], "Please double check the URL or try submitting it again." + should redirect_to("the sign in page") { sign_in_path } + should set_flash[:alert].to "Please double check the URL or try submitting a new password reset." + + should "not sign in the user" do + refute_predicate @controller.request.env[:clearance], :signed_in? end end - context "with mfa enabled" do + context "with totp enabled" do setup do - @user.mfa_ui_only! - get :edit, params: { token: @user.confirmation_token, user_id: @user.id } + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + get :edit, params: { token: @user.confirmation_token } end should respond_with :success + + should "not sign in the user" do + refute_predicate @controller.request.env[:clearance], :signed_in? + end + should "display otp form" do - assert page.has_content?("Multifactor authentication") + assert page.has_content?("Multi-factor authentication") + assert page.has_content?("OTP code") + assert page.has_button?("Authenticate") + end + end + + context "when user has webauthn credentials but no recovery codes" do + setup do + create(:webauthn_credential, user: @user) + @user.new_mfa_recovery_codes = nil + @user.mfa_hashed_recovery_codes = [] + @user.save! + get :edit, params: { token: @user.confirmation_token } + end + + should respond_with :success + + should "not sign in the user" do + refute_predicate @controller.request.env[:clearance], :signed_in? + end + + should "display webauthn prompt" do + assert page.has_button?("Authenticate with security device") + end + + should "not display recovery code prompt" do + refute page.has_content?("Recovery code") + end + end + + context "when user has webauthn credentials and recovery codes" do + setup do + create(:webauthn_credential, user: @user) + get :edit, params: { token: @user.confirmation_token } + end + + should respond_with :success + + should "not sign in the user" do + refute_predicate @controller.request.env[:clearance], :signed_in? + end + + should "display webauthn prompt" do + assert page.has_button?("Authenticate with security device") + end + + should "display recovery code prompt" do + assert page.has_content?("Recovery code") + end + end + + context "when user has webauthn and totp" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + create(:webauthn_credential, user: @user) + get :edit, params: { token: @user.confirmation_token } + end + + should respond_with :success + + should "not sign in the user" do + refute_predicate @controller.request.env[:clearance], :signed_in? + end + + should "display webauthn prompt" do + assert page.has_button?("Authenticate with security device") + end + + should "display otp prompt" do + assert page.has_content?("OTP or recovery code") end end end - context "on POST to mfa_edit" do + context "on POST to otp_edit" do setup do @user = create(:user) @user.forgot_password! end context "with mfa enabled" do - setup { @user.enable_mfa!(ROTP::Base32.random_base32, :ui_only) } + setup { @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) } context "when OTP is correct" do setup do - post :mfa_edit, params: { user_id: @user.id, token: @user.confirmation_token, otp: ROTP::TOTP.new(@user.mfa_seed).now } + get :edit, params: { token: @user.confirmation_token } + post :otp_edit, params: { token: @user.confirmation_token, otp: ROTP::TOTP.new(@user.totp_seed).now } end should respond_with :success + + should "not sign in the user" do + refute_predicate @controller.request.env[:clearance], :signed_in? + end + + should "invalidate the confirmation_token" do + assert_nil @user.reload.confirmation_token + end + should "display edit form" do - assert page.has_content?("Reset password") + assert_text "Reset password" + end + + should "clear mfa_expires_at" do + assert_nil @controller.session[:mfa_expires_at] end end context "when OTP is incorrect" do setup do - post :mfa_edit, params: { user_id: @user.id, token: @user.confirmation_token, otp: "eatthis" } + get :edit, params: { token: @user.confirmation_token } + post :otp_edit, params: { token: @user.confirmation_token, otp: "eatthis" } end should respond_with :unauthorized - should "alert about otp being incorrect" do - assert_equal flash[:alert], "Your OTP code is incorrect." + should set_flash.now[:alert].to "Your OTP code is incorrect." + + should "not sign in the user" do + refute_predicate @controller.request.env[:clearance], :signed_in? + end + end + + context "when the OTP session is expired" do + setup do + get :edit, params: { token: @user.confirmation_token } + travel 16.minutes do + post :otp_edit, params: { token: @user.confirmation_token, otp: ROTP::TOTP.new(@user.totp_seed).now } + end + end + + should set_flash[:alert].to "Your login page session has expired." + should redirect_to("the sign in page") { sign_in_path } + + should "clear mfa_expires_at" do + assert_nil @controller.session[:mfa_expires_at] + end + should "not sign in the user" do + refute_predicate @controller.request.env[:clearance], :signed_in? end end end end - context "on PUT to update" do + context "on POST to webauthn_edit" do setup do @user = create(:user) - @api_key = @user.api_key - @old_encrypted_password = @user.encrypted_password + @webauthn_credential = create(:webauthn_credential, user: @user) + get :edit, params: { token: @user.confirmation_token } + @origin = WebAuthn.configuration.origin + @rp_id = URI.parse(@origin).host + @client = WebAuthn::FakeClient.new(@origin, encoding: false) end - context "with reset_api_key and invalid password" do + context "with webauthn enabled" do setup do - put :update, params: { - user_id: @user.id, - token: @user.confirmation_token, - password_reset: { reset_api_key: "true", password: "pass" } - } + @challenge = session[:webauthn_authentication]["challenge"] + WebauthnHelpers.create_credential( + webauthn_credential: @webauthn_credential, + client: @client + ) + post( + :webauthn_edit, + params: { + token: @user.confirmation_token, + credentials: + WebauthnHelpers.get_result( + client: @client, + challenge: @challenge + ) + } + ) end should respond_with :success - should "not change api_key" do - assert(@user.reload.api_key == @api_key) + + should "not sign in the user" do + refute_predicate @controller.request.env[:clearance], :signed_in? end - should "not change password" do - assert(@user.reload.encrypted_password == @old_encrypted_password) + + should "invalidate the confirmation_token" do + assert_nil @user.reload.confirmation_token + end + + should "display edit form" do + assert_text "Reset password" + end + + should "clear mfa_expires_at" do + assert_nil @controller.session[:mfa_expires_at] + end + end + + context "when providing incorrect token" do + setup do + post(:webauthn_edit, params: { token: "badtoken" }) + end + + should redirect_to("the sign in page") { sign_in_path } + should set_flash[:alert].to "Please double check the URL or try submitting a new password reset." + + should "not sign in the user" do + refute_predicate @controller.request.env[:clearance], :signed_in? + end + end + + context "when not providing credentials" do + setup do + post :webauthn_edit, params: { token: @user.confirmation_token }, format: :html + end + + should respond_with :unauthorized + + should "not sign in the user" do + refute_predicate @controller.request.env[:clearance], :signed_in? + end + + should "set flash notice" do + assert_equal "Credentials required", flash[:alert] + end + end + + context "when providing wrong credential" do + setup do + @wrong_challenge = SecureRandom.hex + WebauthnHelpers.create_credential( + webauthn_credential: @webauthn_credential, + client: @client + ) + post( + :webauthn_edit, + params: { + token: @user.confirmation_token, + credentials: + WebauthnHelpers.get_result( + client: @client, + challenge: @wrong_challenge + ) + } + ) + end + + should respond_with :unauthorized + + should "not sign in the user" do + refute_predicate @controller.request.env[:clearance], :signed_in? + end + + should "set flash notice" do + assert_equal "WebAuthn::ChallengeVerificationError", flash[:alert] + end + + should "still have the webauthn form url" do + assert_not_nil page.find(".js-webauthn-session--form")[:action] end end - context "without reset_api_key and valid password" do + context "when webauthn session is expired" do + setup do + @challenge = session[:webauthn_authentication]["challenge"] + WebauthnHelpers.create_credential( + webauthn_credential: @webauthn_credential, + client: @client + ) + travel 16.minutes do + post( + :webauthn_edit, + params: { + token: @user.confirmation_token, + credentials: + WebauthnHelpers.get_result( + client: @client, + challenge: @challenge + ) + } + ) + end + end + + should redirect_to("the sign in page") { sign_in_path } + should set_flash[:alert] + + should "clear mfa_expires_at" do + assert_nil @controller.session[:mfa_expires_at] + end + + should "not sign in the user" do + refute_predicate @controller.request.env[:clearance], :signed_in? + end + end + end + + context "on PUT to update" do + setup do + @user = create(:user) + @api_key = @user.api_key + @new_api_key = create(:api_key, owner: @user) + @old_encrypted_password = @user.encrypted_password + end + + context "when not verified for password reset" do setup do put :update, params: { - user_id: @user.id, - token: @user.confirmation_token, - password_reset: { password: PasswordHelpers::SECURE_TEST_PASSWORD } + password_reset: { reset_api_key: "true", reset_api_keys: "true", password: PasswordHelpers::SECURE_TEST_PASSWORD } } end - should respond_with :found + should redirect_to("the sign in page") { sign_in_path } + should "not change api_key" do - assert(@user.reload.api_key == @api_key) + assert_equal(@user.reload.api_key, @api_key) end - should "change password" do - assert(@user.reload.encrypted_password != @old_encrypted_password) + should "not change password" do + assert_equal(@user.reload.encrypted_password, @old_encrypted_password) + end + should "not sign in the user" do + refute_predicate @controller.request.env[:clearance], :signed_in? end end - context "with reset_api_key false and valid password" do + context "when not verified for password reset" do setup do put :update, params: { - user_id: @user.id, - token: @user.confirmation_token, - password_reset: { reset_api_key: "false", password: PasswordHelpers::SECURE_TEST_PASSWORD } + password_reset: { password: PasswordHelpers::SECURE_TEST_PASSWORD } } end - should respond_with :found + should redirect_to("the sign in page") { sign_in_path } + should set_flash[:alert].to "Please double check the URL or try submitting a new password reset." + should "not change api_key" do - assert(@user.reload.api_key == @api_key) + assert_equal(@user.reload.api_key, @api_key) end - should "change password" do - assert(@user.reload.encrypted_password != @old_encrypted_password) + should "not change password" do + assert_equal(@user.reload.encrypted_password, @old_encrypted_password) end end - context "with reset_api_key and valid password" do + context "when signed in" do setup do - put :update, params: { - user_id: @user.id, - token: @user.confirmation_token, - password_reset: { reset_api_key: "true", password: PasswordHelpers::SECURE_TEST_PASSWORD } - } + sign_in_as @user + get :edit, params: { token: @user.confirmation_token } end - should respond_with :found - should "change api_key" do - assert(@user.reload.api_key != @api_key) + context "with invalid password" do + setup do + put :update, params: { + password_reset: { reset_api_key: "true", password: "pass" } + } + end + + should respond_with :success + should set_flash.now[:alert].to "Your password could not be changed. Please try again." + + should "not change api_key" do + assert_equal(@user.reload.api_key, @api_key) + end + should "not change password" do + assert_equal(@user.reload.encrypted_password, @old_encrypted_password) + end end - should "change password" do - assert(@user.reload.encrypted_password != @old_encrypted_password) + + context "with a valid password" do + context "when verification has expired" do + setup do + travel 16.minutes do + put :update, params: { + password_reset: { password: PasswordHelpers::SECURE_TEST_PASSWORD } + } + end + end + + should set_flash[:alert] + should redirect_to("the sign in page") { sign_in_path } + + should "not sign the user out" do + assert_predicate @controller.request.env[:clearance], :signed_in? + end + end + + context "without reset_api_key" do + setup do + put :update, params: { + password_reset: { password: PasswordHelpers::SECURE_TEST_PASSWORD } + } + end + + should redirect_to("the dashboard") { dashboard_path } + + should "not change api_key" do + assert_equal(@user.reload.api_key, @api_key) + end + should "change password" do + refute_equal(@user.reload.encrypted_password, @old_encrypted_password) + end + end + + context "with reset_api_key false" do + setup do + put :update, params: { + password_reset: { reset_api_key: "false", password: PasswordHelpers::SECURE_TEST_PASSWORD } + } + end + + should redirect_to("the dashboard") { dashboard_path } + + should "not change api_key" do + assert_equal(@user.reload.api_key, @api_key) + end + should "change password" do + refute_equal(@user.reload.encrypted_password, @old_encrypted_password) + end + end + + context "with reset_api_key" do + setup do + put :update, params: { + password_reset: { reset_api_key: "true", password: PasswordHelpers::SECURE_TEST_PASSWORD } + } + end + + should redirect_to("the dashboard") { dashboard_path } + + should "change api_key" do + refute_equal(@user.reload.api_key, @api_key) + end + should "change password" do + refute_equal(@user.reload.encrypted_password, @old_encrypted_password) + end + should "not delete new api key" do + refute_predicate @new_api_key.reload, :destroyed? + refute_empty @user.reload.api_keys + end + end + + context "with reset_api_key and reset_api_keys" do + setup do + put :update, params: { + password_reset: { reset_api_key: "true", reset_api_keys: "true", password: PasswordHelpers::SECURE_TEST_PASSWORD } + } + end + + should redirect_to("the dashboard") { dashboard_path } + + should "change api_key" do + refute_equal(@user.reload.api_key, @api_key) + end + should "change password" do + refute_equal(@user.reload.encrypted_password, @old_encrypted_password) + end + should "expire new api key" do + assert_empty @user.reload.api_keys.unexpired + refute_empty @user.reload.api_keys.expired + end + end end end end diff --git a/test/functional/profiles_controller_test.rb b/test/functional/profiles_controller_test.rb index 332f012ebc8..1cfbb165ec8 100644 --- a/test/functional/profiles_controller_test.rb +++ b/test/functional/profiles_controller_test.rb @@ -1,9 +1,13 @@ require "test_helper" class ProfilesControllerTest < ActionController::TestCase + include ActionMailer::TestHelper + include ActiveJob::TestHelper + context "for a user that doesn't exist" do should "render not found page" do get :show, params: { id: "unknown" } + assert_response :not_found end end @@ -15,15 +19,29 @@ class ProfilesControllerTest < ActionController::TestCase setup { get :show, params: { id: @user.id } } should respond_with :success - should "render Email link" do - assert page.has_content?("Email Me") - assert page.has_selector?("a[href='mailto:#{@user.email}']") + + should "not render Email link by defaulr" do + refute page.has_selector?("a[href='mailto:#{@user.email}']") end end + context "on GET to me" do + setup { get :me } + + should respond_with :redirect + should redirect_to("the sign in path") { sign_in_path } + end + + context "on GET to security_events" do + setup { get :security_events } + + should respond_with :redirect + should redirect_to("the sign in path") { sign_in_path } + end + context "on GET to show when hide email" do setup do - @user.update(hide_email: true) + @user.update(public_email: false) get :show, params: { id: @user.id } end @@ -65,17 +83,42 @@ class ProfilesControllerTest < ActionController::TestCase end should respond_with :success + should "render user show page" do assert page.has_content? @user.handle end end + context "on GET to me" do + setup do + get :me + end + + should respond_with :redirect + should redirect_to("the user's profile page") { profile_path(@user.handle) } + end + + context "on GET to delete" do + setup do + get :delete + end + + should respond_with :success + + should "render user delete page" do + assert_text "Delete profile" + assert_selector "input[type=password][autocomplete=current-password]" + end + end + context "on GET to edit" do setup { get :edit } should respond_with :success + should "render user edit page" do assert page.has_content? "Edit profile" + assert page.has_css? "input[type=password][autocomplete=current-password]" end end @@ -100,11 +143,11 @@ class ProfilesControllerTest < ActionController::TestCase context "updating show email" do setup do @handle = "john_m_doe" - @hide_email = true + @public_email = true @user = create(:user, handle: "johndoe") sign_in_as(@user) put :update, - params: { user: { handle: @handle, hide_email: @hide_email, password: @user.password } } + params: { user: { handle: @handle, public_email: @public_email, password: @user.password } } end should respond_with :redirect @@ -112,19 +155,40 @@ class ProfilesControllerTest < ActionController::TestCase should set_flash.to("Your profile was updated.") should "update email toggle" do - assert_equal @hide_email, User.last.hide_email + assert_equal @public_email, User.last.public_email + end + end + + context "updating without params" do + setup do + @user = create(:user, handle: "johndoe") + sign_in_as(@user) + put :update, params: {} end + + should respond_with :bad_request end - context "updating without password" do + context "updating with missing password params" do setup do @user = create(:user, handle: "johndoe") sign_in_as(@user) put :update, params: { user: { handle: "doejohn" } } end + should respond_with :bad_request + end + + context "updating without inputting password" do + setup do + @user = create(:user, handle: "johndoe") + sign_in_as(@user) + put :update, params: { user: { handle: "doejohn", password: "" } } + end + should set_flash.to("This request was denied. We could not verify your password.") should redirect_to("the profile edit page") { edit_profile_path } + should "not update handle" do assert_equal "johndoe", @user.handle end @@ -180,23 +244,23 @@ class ProfilesControllerTest < ActionController::TestCase should "set unconfirmed email and confirmation token" do put :update, params: { user: { unconfirmed_email: @new_email, password: @user.password } } + assert_equal @new_email, @user.unconfirmed_email assert @user.confirmation_token end should "not update the current email" do put :update, params: { user: { unconfirmed_email: @new_email, password: @user.password } } + assert_equal @current_email, @user.email end should "send email reset mails to new and current email addresses" do - mailer = mock - mailer.stubs(:deliver) - - Mailer.expects(:email_reset).returns(mailer).times(1) - Mailer.expects(:email_reset_update).returns(mailer).times(1) - put :update, params: { user: { unconfirmed_email: @new_email, password: @user.password } } - Delayed::Worker.new.work_off + assert_enqueued_email_with Mailer, :email_reset, args: [@user] do + assert_enqueued_email_with Mailer, :email_reset_update, args: [@user] do + put :update, params: { user: { unconfirmed_email: @new_email, password: @user.password } } + end + end end end end @@ -205,7 +269,7 @@ class ProfilesControllerTest < ActionController::TestCase context "on DELETE to destroy" do context "correct password" do should "enqueue deletion request" do - assert_difference "Delayed::Job.count", 1 do + assert_enqueued_jobs 1, only: DeleteUserJob do delete :destroy, params: { user: { password: @user.password } } end end @@ -216,14 +280,14 @@ class ProfilesControllerTest < ActionController::TestCase end should redirect_to("the homepage") { root_url } - should set_flash.to("Your account deletion request has been enqueued."\ - " We will send you a confirmation mail when your request has been processed.") + should set_flash.to("Your account deletion request has been enqueued. " \ + "We will send you a confirmation mail when your request has been processed.") end end context "incorrect password" do should "not enqueue deletion request" do - assert_no_difference "Delayed::Job.count" do + assert_enqueued_jobs 0 do post :destroy, params: { user: { password: "youshallnotpass" } } end end @@ -238,6 +302,122 @@ class ProfilesControllerTest < ActionController::TestCase end end end + + context "on GET to security_events" do + setup do + create(:events_user_event, user: @user, tag: Events::UserEvent::LOGIN_SUCCESS) + create(:events_user_event, user: @user, tag: Events::UserEvent::LOGIN_SUCCESS, additional: { authentication_method: "webauthn" }) + create(:events_user_event, user: @user, tag: Events::UserEvent::LOGIN_SUCCESS, additional: { two_factor_method: "webauthn" }) + create(:events_user_event, user: @user, tag: Events::UserEvent::LOGIN_SUCCESS, additional: { two_factor_method: "OTP" }) + + create(:events_user_event, user: @user, tag: Events::UserEvent::EMAIL_SENT) + + create(:events_user_event, user: @user, tag: Events::UserEvent::EMAIL_ADDED, additional: { email: "other@example.com" }) + create(:events_user_event, user: @user, tag: Events::UserEvent::EMAIL_VERIFIED, additional: { email: "other@example.com" }) + + create(:events_user_event, user: @user, tag: Events::UserEvent::API_KEY_CREATED, additional: { gem: create(:rubygem).name }) + create(:events_user_event, user: @user, tag: Events::UserEvent::API_KEY_DELETED) + create(:events_user_event, user: @user, tag: Events::UserEvent::PASSWORD_CHANGED) + + get :security_events + end + + should respond_with :success + end + + context "when user owns a gem with more than MFA_REQUIRED_THRESHOLD downloads" do + setup do + @rubygem = create(:rubygem) + create(:ownership, rubygem: @rubygem, user: @user) + GemDownload.increment( + Rubygem::MFA_REQUIRED_THRESHOLD + 1, + rubygem_id: @rubygem.id + ) + end + + redirect_scenarios = { + "GET to adoptions" => { action: :adoptions, request: { method: "GET", params: { id: 1 } }, path: "/profile/adoptions" }, + "GET to delete" => { action: :delete, request: { method: "GET", params: { id: 1 } }, path: "/profile/delete" }, + "DELETE to destroy" => { action: :destroy, request: { method: "DELETE", params: { id: 1 } }, path: "/profile" }, + "GET to edit" => { action: :edit, request: { method: "GET", params: { id: 1 } }, path: "/profile/edit" }, + "PATCH to update" => { action: :update, request: { method: "PATCH", params: { id: 1 } }, path: "/profile" }, + "PUT to update" => { action: :update, request: { method: "PUT", params: { id: 1 } }, path: "/profile" } + } + + context "user has mfa disabled" do + context "on GET to show" do + setup { get :show, params: { id: @user.id } } + + should "not redirect to mfa" do + assert_response :success + assert page.has_content? "Edit Profile" + end + end + + redirect_scenarios.each do |label, request_params| + context "on #{label}" do + setup { process(request_params[:action], **request_params[:request]) } + + should redirect_to("the edit settings page") { edit_settings_path } + + should "set mfa_redirect_uri" do + assert_equal request_params[:path], @controller.session[:mfa_redirect_uri] + end + end + end + end + + context "user has mfa set to weak level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + end + + context "on GET to show" do + setup { get :show, params: { id: @user.id } } + + should "not redirect to mfa" do + assert_response :success + assert page.has_content? "Edit Profile" + end + end + + redirect_scenarios.each do |label, request_params| + context "on #{label}" do + setup { process(request_params[:action], **request_params[:request]) } + + should redirect_to("the settings page") { edit_settings_path } + + should "set mfa_redirect_uri" do + assert_equal request_params[:path], @controller.session[:mfa_redirect_uri] + end + end + end + end + + context "user has MFA set to strong level, expect normal behaviour" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + end + + context "on GET to show" do + setup { get :show, params: { id: @user.id } } + + should "not redirect to mfa" do + assert_response :success + assert page.has_content? "Edit Profile" + end + end + + context "on GET to adoptions" do + setup { get :adoptions, params: { id: @user.id } } + + should "not redirect to mfa" do + assert_response :success + refute page.has_content? "multi-factor" + end + end + end + end end context "On GET to edit without being signed in" do diff --git a/test/functional/reverse_dependencies_controller_test.rb b/test/functional/reverse_dependencies_controller_test.rb index ed77788571b..040edaf22e3 100644 --- a/test/functional/reverse_dependencies_controller_test.rb +++ b/test/functional/reverse_dependencies_controller_test.rb @@ -23,24 +23,33 @@ class ReverseDependenciesControllerTest < ActionController::TestCase rubygem: @rubygem_two) end - should "render template" do - get :index, params: { rubygem_id: @rubygem_one.to_param } - respond_with :success - render_template :index + context "render template" do + setup do + get :index, params: { rubygem_id: @rubygem_one.slug } + end + + should respond_with :success + should render_template :index end should "show reverse dependencies" do - get :index, params: { rubygem_id: @rubygem_one.to_param } + get :index, params: { rubygem_id: @rubygem_one.slug } + assert page.has_content?(@rubygem_two.name) refute page.has_content?(@rubygem_three.name) + + form_path = rubygem_reverse_dependencies_path(@rubygem_one.slug) + + assert page.has_selector?("form#rdeps-search[action='#{form_path}']") end should "search reverse dependencies" do get :index, params: { - rubygem_id: @rubygem_two.to_param, + rubygem_id: @rubygem_two.slug, rdeps_query: @rubygem_three.name } + assert page.has_content?(@rubygem_three.name) refute page.has_content?(@rubygem_four.name) end @@ -48,9 +57,10 @@ class ReverseDependenciesControllerTest < ActionController::TestCase should "search only current reverse dependencies" do get :index, params: { - rubygem_id: @rubygem_two.to_param, + rubygem_id: @rubygem_two.slug, rdeps_query: @rubygem_one.name } + refute page.has_content?(@rubygem_one.name) end end diff --git a/test/functional/rubygems_controller_test.rb b/test/functional/rubygems_controller_test.rb index 0485e3a5ba7..68c698ac0e1 100644 --- a/test/functional/rubygems_controller_test.rb +++ b/test/functional/rubygems_controller_test.rb @@ -11,7 +11,7 @@ class RubygemsControllerTest < ActionController::TestCase setup do @owners = [@user, create(:user)] @rubygem = create(:rubygem, owners: @owners, number: "1.0.0") - get :show, params: { id: @rubygem.to_param } + get :show, params: { id: @rubygem.slug } end should respond_with :success @@ -27,10 +27,11 @@ class RubygemsControllerTest < ActionController::TestCase @owners = [@user, create(:user)] @rubygem = create(:rubygem, owners: @owners, number: "1.0.0") @rubygem.linkset = nil - get :show, params: { id: @rubygem.to_param } + get :show, params: { id: @rubygem.slug } end should respond_with :success + should "render documentation link" do assert page.has_selector?("a#docs") end @@ -41,7 +42,7 @@ class RubygemsControllerTest < ActionController::TestCase @rubygem = create(:rubygem) create(:version, rubygem: @rubygem) create(:subscription, rubygem: @rubygem, user: @user) - get :show, params: { id: @rubygem.to_param } + get :show, params: { id: @rubygem.slug } end should respond_with :success @@ -55,7 +56,7 @@ class RubygemsControllerTest < ActionController::TestCase setup do @rubygem = create(:rubygem) create(:version, rubygem: @rubygem) - get :show, params: { id: @rubygem.to_param } + get :show, params: { id: @rubygem.slug } end should respond_with :success @@ -64,6 +65,32 @@ class RubygemsControllerTest < ActionController::TestCase refute page.has_content? "Unsubscribe" end end + + context "On GET to security_events for a gem that the user is not an owner of" do + setup { get :security_events, params: { id: create(:rubygem).slug } } + + should respond_with :forbidden + end + + context "On GET to security_events for a gem that the user is an owner of" do + setup do + @rubygem = create(:rubygem) + @other_user = create(:user) + Current.set(user: @user) do + @rubygem.ownerships.create!(user: @other_user, authorizer: @user).destroy! + end + @rubygem.ownerships.create!(user: @user, authorizer: @user).confirm! + get :security_events, params: { id: @rubygem.slug } + end + + should respond_with :success + + should "include the security events" do + assert_text "Owner Added" + assert_text "Owner Confirmed" + assert_text "Owner Removed" + end + end end context "On GET to index with no parameters" do @@ -81,12 +108,9 @@ class RubygemsControllerTest < ActionController::TestCase should "render links" do @gems.each do |g| assert page.has_content?(g.name) - assert page.has_selector?("a[href='#{rubygem_path(g)}']") + page.assert_selector("a[href='#{rubygem_path(g.slug)}']") end end - should "display 'gems' in pagination summary" do - assert page.has_content?("all #{@gems.count} gems") - end end context "On GET to index as an atom feed" do @@ -102,8 +126,8 @@ class RubygemsControllerTest < ActionController::TestCase should "render posts with platform-specific titles and links of all subscribed versions" do @versions.each do |v| assert_select "entry > title", count: 1, text: v.to_title - assert_select "entry > link[href='#{rubygem_version_url(v.rubygem, v.slug)}']", count: 1 - assert_select "entry > id", count: 1, text: rubygem_version_url(v.rubygem, v.slug) + assert_select "entry > link[href='#{rubygem_version_url(v.rubygem.slug, v.slug)}']", count: 1 + assert_select "entry > id", count: 1, text: rubygem_version_url(v.rubygem.slug, v.slug) end end @@ -131,7 +155,7 @@ class RubygemsControllerTest < ActionController::TestCase should respond_with :success should "render links" do assert page.has_content?(@zgem.name) - assert page.has_selector?("a[href='#{rubygem_path(@zgem)}']") + assert page.has_selector?("a[href='#{rubygem_path(@zgem.slug)}']") end end @@ -150,7 +174,7 @@ class RubygemsControllerTest < ActionController::TestCase should "render links" do @gems.each do |g| assert page.has_content?(g.name) - assert page.has_selector?("a[href='#{rubygem_path(g)}']") + assert page.has_selector?("a[href='#{rubygem_path(g.slug)}']") end end end @@ -159,14 +183,15 @@ class RubygemsControllerTest < ActionController::TestCase setup do @latest_version = create(:version, created_at: 1.minute.ago) @rubygem = @latest_version.rubygem - get :show, params: { id: @rubygem.to_param } + get :show, params: { id: @rubygem.slug } end should respond_with :success should "render info about the gem" do assert page.has_content?(@rubygem.name) assert page.has_content?(@latest_version.number) - css = "small:contains('#{@latest_version.created_at.to_date.to_formatted_s(:long)}')" + css = "small:contains('#{@latest_version.authored_at.to_date.to_fs(:long)}')" + assert page.has_css?(css) assert page.has_content?("Links") end @@ -179,17 +204,20 @@ class RubygemsControllerTest < ActionController::TestCase end should "render plural licenses header for other than one license" do @latest_version.update(licenses: nil) - get :show, params: { id: @rubygem.to_param } + get :show, params: { id: @rubygem.slug } + assert page.has_content?("Licenses") @latest_version.update(licenses: %w[MIT GPL-2]) - get :show, params: { id: @rubygem.to_param } + get :show, params: { id: @rubygem.slug } + assert page.has_content?("Licenses") end should "render singular license header for one line license" do @latest_version.update(licenses: ["MIT"]) - get :show, params: { id: @rubygem.to_param } + get :show, params: { id: @rubygem.slug } + assert page.has_content?("License") assert page.has_no_content?("Licenses") end @@ -203,19 +231,21 @@ class RubygemsControllerTest < ActionController::TestCase create(:version, number: "1.9.9", rubygem: @rubygem, created_at: 1.minute.ago), create(:version, number: "1.9.9.rc4", rubygem: @rubygem, created_at: 2.days.ago) ] - get :show, params: { id: @rubygem.to_param } + get :show, params: { id: @rubygem.slug } end should respond_with :success should "render info about the gem" do assert page.has_content?(@rubygem.name) assert page.has_content?(@versions[0].number) - css = "small:contains('#{@versions[0].built_at.to_date.to_formatted_s(:long)}')" + css = "small:contains('#{@versions[0].built_at.to_date.to_fs(:long)}')" + assert page.has_css?(css) assert page.has_content?("Versions") assert page.has_content?(@versions[2].number) - css = "small:contains('#{@versions[2].built_at.to_date.to_formatted_s(:long)}')" + css = "small:contains('#{@versions[2].built_at.to_date.to_fs(:long)}')" + assert page.has_css?(css) end @@ -234,7 +264,7 @@ class RubygemsControllerTest < ActionController::TestCase @rubygem = version.rubygem end context "when signed out" do - setup { get :show, params: { id: @rubygem.to_param } } + setup { get :show, params: { id: @rubygem.slug } } should respond_with :success should "render info about the gem" do assert page.has_content?("This gem is not currently hosted on RubyGems.org") @@ -246,8 +276,9 @@ class RubygemsControllerTest < ActionController::TestCase @user = create(:user) sign_in_as @user create(:subscription, user: @user, rubygem: @rubygem) - get :show, params: { id: @rubygem.to_param } + get :show, params: { id: @rubygem.slug } end + should "have unsubscribe link" do assert page.has_link? "Unsubscribe" end @@ -257,7 +288,7 @@ class RubygemsControllerTest < ActionController::TestCase @rubygem.update(created_at: 30.days.ago, updated_at: 99.days.ago) @owner = create(:user) create(:ownership, user: @owner, rubygem: @rubygem) - get :show, params: { id: @rubygem.to_param } + get :show, params: { id: @rubygem.slug } end should respond_with :success @@ -274,9 +305,10 @@ class RubygemsControllerTest < ActionController::TestCase context "On GET to show for a gem with no versions" do setup do @rubygem = create(:rubygem) - get :show, params: { id: @rubygem.to_param } + get :show, params: { id: @rubygem.slug } end should respond_with :success + should "render info about the gem" do assert page.has_content?("This gem is not currently hosted on RubyGems.org.") end @@ -289,7 +321,7 @@ class RubygemsControllerTest < ActionController::TestCase @development = create(:dependency, :development, version: @version) @runtime = create(:dependency, :runtime, version: @version) - get :show, params: { id: @version.rubygem.to_param } + get :show, params: { id: @version.rubygem.slug } end should respond_with :success @@ -301,6 +333,10 @@ class RubygemsControllerTest < ActionController::TestCase assert page.has_content?(@version.dependencies.runtime.count) assert page.has_content?(@version.dependencies.development.count) end + should "show proper links to dependencies" do + assert page.has_link?(@runtime.rubygem.name, href: "/gems/#{@runtime.rubygem.name}") + assert page.has_link?(@development.rubygem.name, href: "/gems/#{@development.rubygem.name}") + end end context "On GET to show for a gem with dependencies that are unresolved" do @@ -309,10 +345,11 @@ class RubygemsControllerTest < ActionController::TestCase @unresolved = create(:dependency, :unresolved, version: @version) - get :show, params: { id: @version.rubygem.to_param } + get :show, params: { id: @version.rubygem.slug } end should respond_with :success + should "show unresolved dependencies" do assert page.has_content?(@unresolved.name) end @@ -331,13 +368,13 @@ class RubygemsControllerTest < ActionController::TestCase @missing_dependency.rubygem.update_column(:name, "missing") @missing_dependency.update_column(:rubygem_id, nil) - get :show, params: { id: @version.rubygem.to_param } + get :show, params: { id: @version.rubygem.slug } end should respond_with :success - should "show only dependencies that have rubygem" do + should "show dependencies that have rubygem with version" do assert page.has_content?(@runtime.rubygem.name) - assert page.has_no_content?("1.2.0") + assert page.has_content?("1.2.0") end end @@ -346,10 +383,11 @@ class RubygemsControllerTest < ActionController::TestCase @version = create(:version) @runtime = create(:dependency, :runtime, version: @version) @runtime.rubygem.update_column(:name, "foo>0.1.1") - get :show, params: { id: @version.rubygem.to_param } + get :show, params: { id: @version.rubygem.slug } end should respond_with :success + should "show runtime dependencies and development dependencies" do assert page.has_content?(@runtime.rubygem.name) end @@ -363,13 +401,15 @@ class RubygemsControllerTest < ActionController::TestCase should respond_with :not_found end - context "On GET to show for a blacklisted gem" do + context "On GET to show for a reserved gem" do setup do - get :show, params: { id: Patterns::GEM_NAME_BLACKLIST.sample } + reservation = create(:gem_name_reservation) + get :show, params: { id: reservation.name } end should respond_with :success - should "render blacklisted page" do + + should "render reserved page" do assert page.has_content? "This namespace is reserved by rubygems.org." end end @@ -379,10 +419,11 @@ class RubygemsControllerTest < ActionController::TestCase setup do @rubygem = create(:rubygem) create(:version, rubygem: @rubygem) - get :show, params: { id: @rubygem.to_param } + get :show, params: { id: @rubygem.slug } end should respond_with :success + should "have an subscribe link that goes to the sign in page" do assert page.has_selector?("a[href='#{sign_in_path}']") end @@ -390,5 +431,15 @@ class RubygemsControllerTest < ActionController::TestCase refute page.has_selector?("a#unsubscribe") end end + + context "On GET to security_events for a gem" do + setup do + @rubygem = create(:rubygem) + create(:version, rubygem: @rubygem) + get :security_events, params: { id: @rubygem.slug } + end + + should respond_with :redirect + end end end diff --git a/test/functional/searches_controller_test.rb b/test/functional/searches_controller_test.rb index ad18dcf9827..050f11b5236 100644 --- a/test/functional/searches_controller_test.rb +++ b/test/functional/searches_controller_test.rb @@ -1,12 +1,13 @@ require "test_helper" class SearchesControllerTest < ActionController::TestCase - include ESHelper + include SearchKickHelper context "on GET to show with no search parameters" do setup { get :show } should respond_with :success + should "see no results" do refute page.has_content?("Results") end @@ -16,12 +17,14 @@ class SearchesControllerTest < ActionController::TestCase setup do @sinatra = create(:rubygem, name: "sinatra") import_and_refresh - assert_nil @sinatra.versions.most_recent - assert @sinatra.reload.versions.count.zero? + + assert_nil @sinatra.most_recent_version + assert_predicate @sinatra.reload.versions.count, :zero? get :show, params: { query: "sinatra" } end should respond_with :success + should "see no results" do refute page.has_content?("Results") end @@ -42,11 +45,11 @@ class SearchesControllerTest < ActionController::TestCase should respond_with :success should "see sinatra on the page in the results" do assert page.has_content?(@sinatra.name) - assert page.has_selector?("a[href='#{rubygem_path(@sinatra)}']") + assert page.has_selector?("a[href='#{rubygem_path(@sinatra.slug)}']") end should "not see brando on the page in the results" do refute page.has_content?(@brando.name) - refute page.has_selector?("a[href='#{rubygem_path(@brando)}']") + refute page.has_selector?("a[href='#{rubygem_path(@brando.slug)}']") end should "display 'gems' in pagination summary" do assert page.has_content?("all 2 gems") @@ -67,19 +70,19 @@ class SearchesControllerTest < ActionController::TestCase should respond_with :success should "see sinatra on the page in the results" do - page.assert_text(@sinatra.name) - page.assert_selector("a[href='#{rubygem_path(@sinatra)}']") + assert_text @sinatra.name + assert_selector "a[href='#{rubygem_path(@sinatra.slug)}']" end should "not see brando on the page in the results" do - page.assert_no_text(@brando.name) - page.assert_no_selector("a[href='#{rubygem_path(@brando)}']") + refute_text @brando.name + refute_selector "a[href='#{rubygem_path(@brando.slug)}']" end should "display pagination summary" do - page.assert_text("all 2 gems") + assert page.has_text?("all 2 gems") end should "not see suggestions" do - page.assert_no_text("Did you mean") - page.assert_no_selector(".search-suggestions") + refute_text "Did you mean" + refute_selector ".search-suggestions" end end @@ -89,6 +92,7 @@ class SearchesControllerTest < ActionController::TestCase end should respond_with :success + should "see no results" do refute page.has_content?("Results") end @@ -108,19 +112,19 @@ class SearchesControllerTest < ActionController::TestCase should respond_with :success should "see sinatra on the page in the suggestions" do - page.assert_text("Did you mean") - assert page.find(".search__suggestions").has_content?(@sinatra.name) - assert page.has_selector?("a[href='#{search_path(query: @sinatra.name)}']") + assert_text "Did you mean" + assert_text @sinatra.name, page.find(".search__suggestions") + assert_selector "a[href='#{search_path(query: @sinatra.name)}']" end should "not see sinatra on the page in the results" do - page.assert_no_selector("a[href='#{rubygem_path(@sinatra)}']") + refute_selector "a[href='#{rubygem_path(@sinatra.slug)}']" end should "not see brando on the page in the results" do - page.assert_no_text(@brando.name) - page.assert_no_selector("a[href='#{rubygem_path(@brando)}']") + refute_text @brando.name + refute_selector "a[href='#{rubygem_path(@brando.slug)}']" end should "not see filters" do - page.assert_no_text("Filter") + refute_text "Filter" end end @@ -135,11 +139,12 @@ class SearchesControllerTest < ActionController::TestCase end should respond_with :success + should "see sinatra_redux on the page in the results" do - page.assert_selector("a[href='#{rubygem_path(@sinatra_redux)}']") + assert_selector "a[href='#{rubygem_path(@sinatra_redux.slug)}']" end should "not see sinatra on the page in the results" do - page.assert_no_selector("a[href='#{rubygem_path(@sinatra)}']") + refute_selector "a[href='#{rubygem_path(@sinatra.slug)}']" end end @@ -150,13 +155,13 @@ class SearchesControllerTest < ActionController::TestCase create(:version, rubygem: @sinatra) create(:version, rubygem: @sinatra_redux) end - should "fallback to legacy search" do + should "error with friendly error message" do requires_toxiproxy Toxiproxy[:elasticsearch].down do get :show, params: { query: "sinatra" } + assert_response :success - assert page.has_content?("Advanced search is currently unavailable. Falling back to legacy search.") - assert page.has_content?("Displaying") + assert page.has_content?("Search is currently unavailable. Please try again later.") end end end diff --git a/test/functional/sessions_controller_test.rb b/test/functional/sessions_controller_test.rb index ee2b50fd32b..b617beb7842 100644 --- a/test/functional/sessions_controller_test.rb +++ b/test/functional/sessions_controller_test.rb @@ -3,63 +3,84 @@ class SessionsControllerTest < ActionController::TestCase context "when user has mfa enabled" do setup do - @user = User.new(email_confirmed: true, handle: "test") - @user.enable_mfa!(ROTP::Base32.random_base32, :ui_only) - @request.cookies[:mfa_feature] = "true" + @user = create(:user, email_confirmed: true, handle: "login") + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) end context "on POST to create" do setup do - User.expects(:authenticate).with("login", "pass").returns @user - post :create, params: { session: { who: "login", password: "pass" } } + @current_time = Time.utc(2023, 1, 1, 0, 0, 0) + travel_to @current_time + freeze_time + + post :create, params: { session: { who: "login", password: PasswordHelpers::SECURE_TEST_PASSWORD } } end should respond_with :success - should "save user name in session" do - assert @controller.session[:mfa_user] == @user.handle - assert page.has_content? "Multifactor authentication" + should "save user id in session" do + assert_equal @controller.session[:mfa_user], @user.id + assert page.has_content? "Multi-factor authentication" + end + + should "set mfa_login_started_at in session " do + assert_equal @current_time, @controller.session[:mfa_login_started_at] + end + + teardown do + travel_back end end - context "on POST to mfa_create" do + context "on POST to otp_create" do + setup do + @current_time = Time.utc(2023, 1, 1, 0, 0, 0) + travel_to @current_time + freeze_time + + post :create, params: { session: { who: "login", password: PasswordHelpers::SECURE_TEST_PASSWORD } } + end + context "when OTP is correct" do setup do - @controller.session[:mfa_user] = @user.handle - post :mfa_create, params: { otp: ROTP::TOTP.new(@user.mfa_seed).now } + @controller.session[:mfa_user] = @user.id + post :otp_create, params: { otp: ROTP::TOTP.new(@user.totp_seed).now } end should respond_with :redirect should redirect_to("the dashboard") { dashboard_path } + should "clear user name in session" do - assert @controller.session[:mfa_user].nil? + assert_nil @controller.session[:mfa_user] end should "make user logged in" do - assert @controller.request.env[:clearance].signed_in? + assert_predicate @controller.request.env[:clearance], :signed_in? end end context "when OTP is recovery code" do setup do - @controller.session[:mfa_user] = @user.handle - post :mfa_create, params: { otp: @user.mfa_recovery_codes.first } + @controller.session[:mfa_user] = @user.id + post :otp_create, params: { otp: @user.new_mfa_recovery_codes.first } end should respond_with :redirect should redirect_to("the dashboard") { dashboard_path } + should "clear user name in session" do - assert @controller.session[:mfa_user].nil? + assert_nil @controller.session[:mfa_user] end should "make user logged in" do - assert @controller.request.env[:clearance].signed_in? + assert_predicate @controller.request.env[:clearance], :signed_in? end end context "when OTP is incorrect" do setup do - wrong_otp = (ROTP::TOTP.new(@user.mfa_seed).now.to_i.succ % 1_000_000).to_s - post :mfa_create, params: { otp: wrong_otp } + @controller.session[:mfa_user] = @user.id + wrong_otp = (ROTP::TOTP.new(@user.totp_seed).now.to_i.succ % 1_000_000).to_s + post :otp_create, params: { otp: wrong_otp } end should set_flash.now[:notice] @@ -70,36 +91,212 @@ class SessionsControllerTest < ActionController::TestCase end should "not sign in the user" do - refute @controller.request.env[:clearance].signed_in? + refute_predicate @controller.request.env[:clearance], :signed_in? end should "clear user name in session" do assert_nil @controller.session[:mfa_user] end end + + context "when mfa code is correct" do + setup do + @start_time = @current_time + @end_time = Time.utc(2023, 1, 1, 0, 2, 0) + @duration = @end_time - @start_time + @controller.session[:mfa_user] = @user.id + end + + should "record duration on successful OTP login" do + StatsD.expects(:distribution).with("login.mfa.otp.duration", @duration) + + travel_to @end_time do + post :otp_create, params: { otp: ROTP::TOTP.new(@user.totp_seed).now } + end + end + + should "record duration on successful recovery code login" do + StatsD.expects(:distribution).with("login.mfa.otp.duration", @duration) + + travel_to @end_time do + post :otp_create, params: { otp: @user.new_mfa_recovery_codes.first } + end + end + end + + teardown do + travel_back + end + end + + context "when OTP is correct but session expired" do + setup do + post :create, params: { session: { who: "login", password: PasswordHelpers::SECURE_TEST_PASSWORD } } + + travel 30.minutes + + post :otp_create, params: { otp: ROTP::TOTP.new(@user.totp_seed).now } + end + + should set_flash.now[:notice] + should respond_with :unauthorized + + should "clear mfa_expires_at" do + assert_nil @controller.session[:mfa_expires_at] + end + + should "render sign in page" do + assert page.has_content? "Sign in" + end + + should "not sign in the user" do + refute_predicate @controller.request.env[:clearance], :signed_in? + end + + should "clear user name in session" do + assert_nil @controller.session[:mfa_user] + end + end + + context "when no mfa_expires_at session is present" do + setup do + @controller.session[:mfa_user] = @user.id + + post :otp_create, params: { otp: ROTP::TOTP.new(@user.totp_seed).now } + end + + should respond_with :unauthorized + + should "not sign in the user" do + refute_predicate @controller.request.env[:clearance], :signed_in? + end + + should "display the error message" do + assert page.has_content? "Your login page session has expired." + end + end + + context "when mfa session is missing mfa_user" do + setup do + @controller.session[:mfa_expires_at] = 15.minutes.from_now.to_s + + post :otp_create, params: { otp: ROTP::TOTP.new(@user.totp_seed).now } + end + + should respond_with :unauthorized + + should "not sign in the user" do + refute_predicate @controller.request.env[:clearance], :signed_in? + end + + should "display the error message" do + assert page.has_content? "Your login page session has expired." + end end end context "on POST to create" do context "when login and password are correct" do setup do - user = User.new(email_confirmed: true) - User.expects(:authenticate).with("login", "pass").returns user - post :create, params: { session: { who: "login", password: "pass" } } + @user = create(:user, handle: "login") + @controller.session[:mfa_expires_at] = 15.minutes.from_now.to_s end - should respond_with :redirect - should redirect_to("the dashboard") { dashboard_path } + context "when mfa is not recommended" do + setup do + post :create, params: { session: { who: "login", password: PasswordHelpers::SECURE_TEST_PASSWORD } } + end - should "sign in the user" do - assert @controller.request.env[:clearance].signed_in? + should respond_with :redirect + should redirect_to("the dashboard") { dashboard_path } + + should "sign in the user" do + assert_predicate @controller.request.env[:clearance], :signed_in? + end + + should "set security device notice" do + expected_notice = "🎉 We now support security devices! Improve your account security by " \ + "setting up a new device. " \ + "Learn more!" + + assert_equal expected_notice, flash[:notice_html] + assert_nil flash[:notice] + end + end + + context "when mfa is recommended" do + setup do + User.any_instance.stubs(:mfa_recommended?).returns true + end + + context "when mfa is disabled" do + setup do + post :create, params: { session: { who: "login", password: PasswordHelpers::SECURE_TEST_PASSWORD } } + end + + should respond_with :redirect + should redirect_to("the mfa setup page") { new_totp_path } + + should "set notice flash" do + expected_notice = "For protection of your account and your gems, we encourage you to set up multi-factor authentication. " \ + "Your account will be required to have MFA enabled in the future." + + assert_equal expected_notice, flash[:notice] + assert_nil flash[:notice_html] + end + end + + context "when mfa is enabled" do + setup do + @controller.session[:mfa_login_started_at] = Time.now.utc.to_s + @controller.session[:mfa_user] = @user.id + end + + context "on `ui_only` level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + post :otp_create, params: { otp: ROTP::TOTP.new(@user.totp_seed).now } + end + + should respond_with :redirect + should redirect_to("the settings page") { edit_settings_path } + + should "set notice flash" do + expected_notice = "For protection of your account and your gems, we encourage you to change your MFA level " \ + "to \"UI and gem signin\" or \"UI and API\". Your account will be required to have MFA enabled " \ + "on one of these levels in the future." + + assert_equal expected_notice, flash[:notice] + assert_nil flash[:notice_html] + end + end + + context "on `ui_and_gem_signin` level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_gem_signin) + post :otp_create, params: { otp: ROTP::TOTP.new(@user.totp_seed).now } + end + + should respond_with :redirect + should redirect_to("the dashboard") { dashboard_path } + end + + context "on `ui_and_api` level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + post :otp_create, params: { otp: ROTP::TOTP.new(@user.totp_seed).now } + end + + should respond_with :redirect + should redirect_to("the dashboard") { dashboard_path } + end + end end end context "when login and password are incorrect" do setup do - User.expects(:authenticate).with("login", "pass") - post :create, params: { session: { who: "login", password: "pass" } } + post :create, params: { session: { who: "login", password: "incorrectpassword" } } end should respond_with :unauthorized @@ -110,18 +307,19 @@ class SessionsControllerTest < ActionController::TestCase end should "not sign in the user" do - refute @controller.request.env[:clearance].signed_in? + refute_predicate @controller.request.env[:clearance], :signed_in? end end - context "when login is an array" do + context "when login params are invalid" do setup do - post :create, params: { session: { who: ["1"], password: "pass" } } + post :create, params: { session: { who: ["1"], password: PasswordHelpers::SECURE_TEST_PASSWORD } } end - should respond_with :unauthorized + should respond_with :bad_request + should "not sign in the user" do - refute @controller.request.env[:clearance].signed_in? + refute_predicate @controller.request.env[:clearance], :signed_in? end end @@ -133,6 +331,128 @@ class SessionsControllerTest < ActionController::TestCase should respond_with :unauthorized end + + context "when user has mfa enabled" do + setup do + @user = create(:user, :mfa_enabled) + post( + :create, + params: { session: { who: @user.handle, password: @user.password } } + ) + end + + should respond_with :ok + + should "not set webauthn_authentication" do + assert_nil session[:webauthn_authentication] + end + + should "set mfa_user" do + assert_equal @user.id, session[:mfa_user] + end + + should "have mfa forms and not webauthn credentials form" do + assert page.has_content?("multi-factor authentication") + assert page.has_field?("OTP or recovery code") + assert page.has_button?("Authenticate") + end + end + + context "when user has webauthn credentials" do + setup do + @user = create(:user) + create(:webauthn_credential, user: @user) + post( + :create, + params: { session: { who: @user.handle, password: @user.password } } + ) + end + + should respond_with :ok + + should "set webauthn authentication" do + assert_not_nil session[:webauthn_authentication]["challenge"] + end + + should "set mfa_user" do + assert_equal @user.id, session[:mfa_user] + end + + should "have recovery code form if user has recovery codes" do + assert page.has_content?("Multi-factor authentication") + assert page.has_content?("Recovery code") + assert page.has_button?("Authenticate") + end + + should "not have mfa forms and have webauthn credentials form" do + assert page.has_content?("Multi-factor authentication") + assert_not page.has_field?("OTP code") + assert page.has_button?("Authenticate with security device") + end + + should "not set security device notice" do + assert_nil flash[:notice_html] + end + end + + context "when user has webauthn credentials but no recovery code" do + setup do + @user = create(:user) + create(:webauthn_credential, user: @user) + @user.new_mfa_recovery_codes = nil + @user.mfa_hashed_recovery_codes = [] + @user.save! + post( + :create, + params: { session: { who: @user.handle, password: @user.password } } + ) + end + + should respond_with :ok + + should "set webauthn authentication" do + assert_not_nil session[:webauthn_authentication]["challenge"] + end + + should "set mfa_user" do + assert_equal @user.id, session[:mfa_user] + end + + should "not have mfa forms and have webauthn credentials form" do + assert page.has_content?("Multi-factor authentication") + assert_not page.has_field?("OTP code") + assert_not page.has_content?("Recovery code") + assert page.has_button?("Authenticate with security device") + end + end + + context "when user has mfa enabled and webauthn credentials" do + setup do + @user = create(:user, :mfa_enabled) + create(:webauthn_credential, user: @user) + post( + :create, + params: { session: { who: @user.handle, password: @user.password } } + ) + end + + should respond_with :ok + + should "set webauthn authentication" do + assert_not_nil session[:webauthn_authentication]["challenge"] + end + + should "set mfa_user" do + assert_equal @user.id, session[:mfa_user] + end + + should "have mfa forms and webauthn credentials form" do + assert page.has_content?("multi-factor authentication") + assert page.has_field?("OTP or recovery code") + assert page.has_button?("Authenticate") + assert page.has_button?("Authenticate with security device") + end + end end context "on DELETE to destroy" do @@ -144,14 +464,25 @@ class SessionsControllerTest < ActionController::TestCase should redirect_to("login page") { sign_in_path } should "sign out the user" do - refute @controller.request.env[:clearance].signed_in? + refute_predicate @controller.request.env[:clearance], :signed_in? + end + end + + context "on GET to new" do + setup do + get :new + end + + should "render sign-in form" do + assert_text "Sign in" + assert_selector "input[type=password][autocomplete=current-password]" end end context "on GET to verify" do setup do rubygem = create(:rubygem) - session[:redirect_uri] = rubygem_owners_url(rubygem) + session[:redirect_uri] = rubygem_owners_url(rubygem.slug) end context "when signed in" do @@ -161,8 +492,10 @@ class SessionsControllerTest < ActionController::TestCase get :verify, params: { user_id: user.id } end should respond_with :success + should "render password verification form" do assert page.has_css? "#verify_password_password" + assert page.has_css? "input[type=password][autocomplete=current-password]" end end @@ -178,29 +511,30 @@ class SessionsControllerTest < ActionController::TestCase context "on POST to authenticate" do setup do rubygem = create(:rubygem) - session[:redirect_uri] = rubygem_owners_url(rubygem) + session[:redirect_uri] = rubygem_owners_url(rubygem.slug) end context "when signed in" do + setup do + @user = create(:user) + @rubygem = create(:rubygem) + sign_in_as(@user) + session[:redirect_uri] = rubygem_owners_url(@rubygem.slug) + end + context "on correct password" do setup do - user = create(:user) - @rubygem = create(:rubygem) - sign_in_as(user) - session[:redirect_uri] = rubygem_owners_url(@rubygem) - post :authenticate, params: { user_id: user.id, verify_password: { password: PasswordHelpers::SECURE_TEST_PASSWORD } } + post :authenticate, params: { user_id: @user.id, verify_password: { password: PasswordHelpers::SECURE_TEST_PASSWORD } } end - should redirect_to("redirect uri") { rubygem_owners_path(@rubygem) } + should redirect_to("redirect uri") { rubygem_owners_path(@rubygem.slug) } end + context "on incorrect password" do setup do - @user = create(:user) - @rubygem = create(:rubygem) - sign_in_as(@user) - session[:redirect_uri] = rubygem_owners_url(@rubygem) post :authenticate, params: { user_id: @user.id, verify_password: { password: "wrong password" } } end should respond_with :unauthorized + should "show error flash" do assert_equal "This request was denied. We could not verify your password.", flash[:alert] end @@ -215,4 +549,273 @@ class SessionsControllerTest < ActionController::TestCase should redirect_to("sign in") { sign_in_path } end end + + context "on POST to webauthn_create" do + setup do + @user = create(:user) + @webauthn_credential = create(:webauthn_credential, user: @user) + login_to_session_with_webauthn + end + + context "when providing correct credentials" do + context "redirect to dashboard" do + setup do + verify_challenge + end + + should redirect_to :dashboard + end + + should "log in the user" do + verify_challenge + + assert_predicate @controller.request.env[:clearance], :signed_in? + end + + should "record mfa login duration" do + start_time = Time.utc(2023, 1, 1, 0, 0, 0) + end_time = Time.utc(2023, 1, 1, 0, 2, 0) + duration = end_time - start_time + + StatsD.expects(:distribution).with("login.mfa.webauthn.duration", duration) + + travel_to start_time do + login_to_session_with_webauthn + end + + travel_to end_time do + verify_challenge + end + end + + should "clear session" do + verify_challenge + + assert_nil @controller.session[:mfa_expires_at] + assert_nil @controller.session[:mfa_login_started_at] + assert_nil @controller.session[:mfa_user] + assert_nil @controller.session[:webauthn_authentication] + end + end + + context "when not providing credentials" do + setup do + @existing_webauthn = @controller.session[:webauthn_authentication] + post( + :webauthn_create, + format: :html + ) + end + + should respond_with :unauthorized + + should "set flash notice" do + assert_equal "Credentials required", flash[:notice] + end + + should "render sign in page" do + assert_template "sessions/new" + refute_nil @controller.session[:webauthn_authentication] + refute_equal @existing_webauthn, @controller.session[:webauthn_authentication] + end + + should "clear session" do + assert_nil @controller.session[:mfa_expires_at] + assert_nil @controller.session[:mfa_login_started_at] + assert_nil @controller.session[:mfa_user] + end + end + + context "when providing wrong credentials" do + setup do + @existing_webauthn = @controller.session[:webauthn_authentication] + @wrong_challenge = SecureRandom.hex + post( + :webauthn_create, + params: { + credentials: + WebauthnHelpers.get_result( + client: @client, + challenge: @wrong_challenge + ) + }, + format: :html + ) + end + + should respond_with :unauthorized + + should "set flash notice" do + assert_equal "WebAuthn::ChallengeVerificationError", flash[:notice] + end + + should "render sign in page" do + assert_template "sessions/new" + refute_nil @controller.session[:webauthn_authentication] + refute_equal @existing_webauthn, @controller.session[:webauthn_authentication] + end + + should "clear session" do + assert_nil @controller.session[:mfa_expires_at] + assert_nil @controller.session[:mfa_login_started_at] + assert_nil @controller.session[:mfa_user] + end + end + + context "when providing credentials but the session expired" do + setup do + travel 30.minutes + @existing_webauthn = @controller.session[:webauthn_authentication] + + post( + :webauthn_create, + params: { + credentials: + WebauthnHelpers.get_result( + client: @client, + challenge: @challenge + ) + }, + format: :html + ) + end + + should respond_with :unauthorized + + should "clear session" do + assert_nil @controller.session[:mfa_expires_at] + assert_nil @controller.session[:mfa_login_started_at] + assert_nil @controller.session[:mfa_user] + end + + should "not sign in the user" do + refute_predicate @controller.request.env[:clearance], :signed_in? + end + + should "set flash notice" do + assert_equal "Your login page session has expired.", flash[:notice] + end + + should "render sign in page" do + assert_template "sessions/new" + refute_nil @controller.session[:webauthn_authentication] + refute_equal @existing_webauthn, @controller.session[:webauthn_authentication] + end + end + end + + context "when user owns a gem with more than MFA_REQUIRED_THRESHOLD downloads" do + setup do + @user = create(:user) + sign_in_as(@user) + @rubygem = create(:rubygem) + session[:redirect_uri] = rubygem_owners_url(@rubygem.slug) + create(:ownership, rubygem: @rubygem, user: @user) + GemDownload.increment( + Rubygem::MFA_REQUIRED_THRESHOLD + 1, + rubygem_id: @rubygem.id + ) + end + + context "user has mfa disabled" do + context "on GET to verify" do + setup { get :verify, params: { user_id: @user.id } } + + should redirect_to("the edit settings page") { edit_settings_path } + + should "set mfa_redirect_uri" do + assert_equal verify_session_path, session[:mfa_redirect_uri] + end + end + + context "on POST to authenticate" do + setup { post :authenticate, params: { user_id: @user.id, verify_password: { password: PasswordHelpers::SECURE_TEST_PASSWORD } } } + + should redirect_to("the edit settings page") { edit_settings_path } + + should "set mfa_redirect_uri" do + assert_equal authenticate_session_path, session[:mfa_redirect_uri] + end + end + end + + context "user has mfa set to weak level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + end + + context "on GET to verify" do + setup { get :verify, params: { user_id: @user.id } } + + should redirect_to("the settings page") { edit_settings_path } + + should "set mfa_redirect_uri" do + assert_equal verify_session_path, session[:mfa_redirect_uri] + end + end + + context "on POST to authenticate" do + setup { post :authenticate, params: { user_id: @user.id, verify_password: { password: PasswordHelpers::SECURE_TEST_PASSWORD } } } + + should redirect_to("the settings page") { edit_settings_path } + + should "set mfa_redirect_uri" do + assert_equal authenticate_session_path, session[:mfa_redirect_uri] + end + end + end + + context "user has MFA set to strong level, expect normal behaviour" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + end + + context "on GET to verify" do + setup { get :verify, params: { user_id: @user.id } } + + should respond_with :success + + should "render password verification form" do + assert page.has_css? "#verify_password_password" + end + end + + context "on POST to authenticate" do + setup { post :authenticate, params: { user_id: @user.id, verify_password: { password: PasswordHelpers::SECURE_TEST_PASSWORD } } } + + should redirect_to("redirect uri") { rubygem_owners_path(@rubygem.slug) } + end + end + end + + private + + def login_to_session_with_webauthn + post( + :create, + params: { session: { who: @user.handle, password: @user.password } } + ) + @challenge = session[:webauthn_authentication]["challenge"] + @origin = WebAuthn.configuration.origin + @rp_id = URI.parse(@origin).host + @client = WebAuthn::FakeClient.new(@origin, encoding: false) + WebauthnHelpers.create_credential( + webauthn_credential: @webauthn_credential, + client: @client + ) + end + + def verify_challenge + post( + :webauthn_create, + params: { + credentials: + WebauthnHelpers.get_result( + client: @client, + challenge: @challenge + ) + }, + format: :html + ) + end end diff --git a/test/functional/settings_controller_test.rb b/test/functional/settings_controller_test.rb new file mode 100644 index 00000000000..ac860915945 --- /dev/null +++ b/test/functional/settings_controller_test.rb @@ -0,0 +1,61 @@ +require "test_helper" + +class SettingsControllerTest < ActionController::TestCase + context "when not logged in" do + setup do + @user = create(:user) + get :edit + end + should redirect_to("the sign in page") { sign_in_path } + end + + context "when logged in" do + setup do + @user = create(:user) + sign_in_as(@user) + end + + context "when user owns a gem with more than MFA_REQUIRED_THRESHOLD downloads" do + setup do + @rubygem = create(:rubygem) + create(:ownership, rubygem: @rubygem, user: @user) + GemDownload.increment( + Rubygem::MFA_REQUIRED_THRESHOLD + 1, + rubygem_id: @rubygem.id + ) + end + + context "user has mfa disabled" do + setup { get :edit } + should "flash a warning message" do + assert_response :success + assert page.has_content? "For protection of your account and your gems, you are required to set up multi-factor authentication." + end + end + + context "user has mfa set to weak level" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + get :edit + end + + should "stay on edit settings page without redirecting" do + assert_response :success + assert page.has_content? "Edit settings" + end + end + + context "user has MFA set to strong level, expect normal behaviour" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + get :edit + end + + should "stay on edit settings page without redirecting" do + assert_response :success + assert page.has_content? "Edit settings" + end + end + end + end +end diff --git a/test/functional/stats_controller_test.rb b/test/functional/stats_controller_test.rb index c349ad67f37..84768c6adf8 100644 --- a/test/functional/stats_controller_test.rb +++ b/test/functional/stats_controller_test.rb @@ -58,11 +58,13 @@ class StatsControllerTest < ActionController::TestCase end should "not have width greater than 100%" do - assert_select ".stats__graph__gem__meter" do |element| - element.map { |h| h[:style] }.each do |width| - width =~ /width: (\d+[,.]\d+)%/ - assert Regexp.last_match(1).to_f <= 100, "#{Regexp.last_match(1)} is greater than 100" - end + assert page.has_selector?(".stats__graph__gem__meter") + + page.find_all(".stats__graph__gem__meter").each do |element| + assert element["data-stats-width-value"] + width = element["data-stats-width-value"].to_f + + assert_operator width, :<=, 100, "#{width} is greater than 100" end end end diff --git a/test/functional/subscriptions_controller_test.rb b/test/functional/subscriptions_controller_test.rb index af0cc01bf9f..d0ce9a69bc3 100644 --- a/test/functional/subscriptions_controller_test.rb +++ b/test/functional/subscriptions_controller_test.rb @@ -9,10 +9,11 @@ class SubscriptionsControllerTest < ActionController::TestCase context "On POST to create for a gem that the user is not subscribed to" do setup do - post :create, params: { rubygem_id: @rubygem.to_param } + post :create, params: { rubygem_id: @rubygem.slug } end - should redirect_to("rubygems show") { rubygem_path(@rubygem) } + should redirect_to("rubygems show") { rubygem_path(@rubygem.slug) } + should "not set flash error" do assert_nil flash[:error] end @@ -21,33 +22,36 @@ class SubscriptionsControllerTest < ActionController::TestCase context "On POST to create for a gem that the user is subscribed to" do setup do create(:subscription, rubygem: @rubygem, user: @user) - post :create, params: { rubygem_id: @rubygem.to_param } + post :create, params: { rubygem_id: @rubygem.slug } end - should redirect_to("rubygems show") { rubygem_path(@rubygem) } + should redirect_to("rubygems show") { rubygem_path(@rubygem.slug) } + should "set flash error" do - assert_equal flash[:error], "Something went wrong. Please try again." + assert_equal "Something went wrong. Please try again.", flash[:error] end end context "On DELETE to destroy for a gem that the user is not subscribed to" do setup do - delete :destroy, params: { rubygem_id: @rubygem.to_param } + delete :destroy, params: { rubygem_id: @rubygem.slug } end - should redirect_to("rubygems show") { rubygem_path(@rubygem) } + should redirect_to("rubygems show") { rubygem_path(@rubygem.slug) } + should "set flash error" do - assert_equal flash[:error], "Something went wrong. Please try again." + assert_equal "Something went wrong. Please try again.", flash[:error] end end context "On DELETE to destroy for a gem that the user is subscribed to" do setup do create(:subscription, rubygem: @rubygem, user: @user) - delete :destroy, params: { rubygem_id: @rubygem.to_param } + delete :destroy, params: { rubygem_id: @rubygem.slug } end - should redirect_to("rubygems show") { rubygem_path(@rubygem) } + should redirect_to("rubygems show") { rubygem_path(@rubygem.slug) } + should "not set flash error" do assert_nil flash[:error] end diff --git a/test/functional/totps_controller_test.rb b/test/functional/totps_controller_test.rb new file mode 100644 index 00000000000..61cf972614f --- /dev/null +++ b/test/functional/totps_controller_test.rb @@ -0,0 +1,244 @@ +require "test_helper" + +class TotpsControllerTest < ActionController::TestCase + include ActionMailer::TestHelper + + context "when logged in" do + setup do + @user = create(:user) + sign_in_as(@user) + @request.cookies[:mfa_feature] = "true" + end + + context "when totp is enabled" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + end + + context "on GET to new totp" do + setup do + get :new + end + + should respond_with :redirect + should redirect_to("the settings page") { edit_settings_path } + + should "say TOTP is already enabled" do + assert_equal "Your OTP based multi-factor authentication has already been enabled. " \ + "To reconfigure your OTP based authentication, you'll have to remove it first.", flash[:error] + end + end + + context "on POST to create mfa" do + setup do + @seed = ROTP::Base32.random_base32 + @controller.session[:totp_seed] = @seed + @controller.session[:totp_seed_expire] = Gemcutter::MFA_KEY_EXPIRY.from_now.utc.to_i + post :create, params: { otp: ROTP::TOTP.new(@user.totp_seed).now } + end + + should respond_with :redirect + should redirect_to("the settings page") { edit_settings_path } + + should "keep mfa enabled" do + assert_predicate @user.reload, :mfa_enabled? + assert_emails 0 + end + + should "say TOTP is already enabled" do + assert_equal "Your OTP based multi-factor authentication has already been enabled. " \ + "To reconfigure your OTP based authentication, you'll have to remove it first.", flash[:error] + end + end + + context "on DELETE to destroy" do + context "with correct OTP" do + setup do + @controller.session["mfa_redirect_uri"] = edit_settings_path + + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + delete :destroy, params: { otp: ROTP::TOTP.new(@user.totp_seed).now } + end + end + + should respond_with :redirect + should redirect_to("the settings page") { edit_settings_path } + + should "disable mfa and clear recovery codes" do + assert_predicate @user.reload, :totp_disabled? + assert_predicate @user.reload, :mfa_disabled? + assert_empty @user.mfa_hashed_recovery_codes + end + + should "send mfa disabled email" do + assert_emails 1 + + assert_equal "Authentication app disabled on RubyGems.org", + last_email.subject + assert_equal [@user.email], last_email.to + end + + should "flash success" do + assert_equal "You have successfully disabled OTP based multi-factor authentication.", flash[:success] + end + + should "delete mfa_redirect_uri from session" do + assert_nil session[:mfa_redirect_uri] + end + end + + context "with incorrect OTP" do + setup do + @controller.session["mfa_redirect_uri"] = edit_settings_path + + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + delete :destroy, params: { otp: "123456" } + end + end + + should respond_with :redirect + should redirect_to("the settings page") { edit_settings_path } + + should "keep mfa and recovery codes enabled" do + assert_predicate @user.reload, :totp_enabled? + assert_not_empty @user.mfa_hashed_recovery_codes + end + + should "flash error" do + assert_equal "Your OTP code is incorrect.", flash[:error] + end + + should "not send mfa disabled email" do + assert_emails 0 + end + + should "not clear mfa_redirect_uri from session" do + assert_not_nil session[:mfa_redirect_uri] + end + end + end + end + + context "when a webauthn device is enabled" do + setup do + @webauthn_credential = create(:webauthn_credential, user: @user) + @user.update!(mfa_level: :ui_only) + end + + context "on POST to create totp mfa" do + setup do + @seed = ROTP::Base32.random_base32 + @controller.session[:totp_seed] = @seed + @controller.session[:totp_seed_expire] = Gemcutter::MFA_KEY_EXPIRY.from_now.utc.to_i + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + post :create, params: { otp: ROTP::TOTP.new(@seed).now } + end + end + + should redirect_to("the edit settings page") { edit_settings_path } + + should "send totp enabled email" do + assert_emails 1 + assert_equal "Authentication app enabled on RubyGems.org", + last_email.subject + assert_equal [@user.email], last_email.to + end + end + + context "on DELETE to destroy" do + should "redirect to settings page" do + delete :destroy + + assert_redirected_to edit_settings_path + end + + should "not change mfa level and recovery codes" do + assert_no_changes -> { [@user.reload.mfa_level, @user.reload.mfa_hashed_recovery_codes] } do + delete :destroy + end + end + + should "display flash error" do + delete :destroy + + assert_equal "You don't have an authenticator app enabled. You have to enable it first.", flash[:error] + end + end + end + + context "when there are no mfa devices" do + context "on POST to create totp mfa" do + setup do + @seed = ROTP::Base32.random_base32 + @controller.session[:totp_seed] = @seed + end + + context "when qr-code is not expired" do + setup do + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + @controller.session[:totp_seed_expire] = Gemcutter::MFA_KEY_EXPIRY.from_now.utc.to_i + post :create, params: { otp: ROTP::TOTP.new(@seed).now } + end + end + + should redirect_to("the recovery page") { recovery_multifactor_auth_path } + + should "enable mfa" do + assert_predicate @user.reload, :mfa_enabled? + end + + should "send totp enabled email" do + assert_emails 1 + assert_equal "Authentication app enabled on RubyGems.org", + last_email.subject + assert_equal [@user.email], last_email.to + end + + should "flash success" do + assert_equal "You have successfully enabled OTP based multi-factor authentication.", flash[:success] + end + end + + context "when qr-code is expired" do + setup do + @controller.session[:totp_seed_expire] = 1.minute.ago + post :create, params: { otp: ROTP::TOTP.new(@seed).now } + end + + should respond_with :redirect + should redirect_to("the settings page") { edit_settings_path } + + should "set error flash message" do + refute_empty flash[:error] + end + should "keep mfa disabled" do + refute_predicate @user.reload, :mfa_enabled? + end + should "not send mfa enabled email" do + assert_emails 0 + end + end + end + + context "on DELETE to destroy" do + should "redirect to settings page" do + delete :destroy + + assert_redirected_to edit_settings_path + end + + should "not change mfa level and recovery codes" do + assert_no_changes -> { [@user.reload.mfa_level, @user.reload.mfa_hashed_recovery_codes] } do + delete :destroy + end + end + + should "display flash error" do + delete :destroy + + assert_equal "You don't have an authenticator app enabled. You have to enable it first.", flash[:error] + end + end + end + end +end diff --git a/test/functional/users_controller_test.rb b/test/functional/users_controller_test.rb index 220fd6092a2..0173d72abb3 100644 --- a/test/functional/users_controller_test.rb +++ b/test/functional/users_controller_test.rb @@ -1,26 +1,45 @@ require "test_helper" class UsersControllerTest < ActionController::TestCase + include ActiveJob::TestHelper + context "on GET to new" do setup do get :new end - render_template(:new) + should render_template(:new) + + should "render the new user form" do + assert_text "Sign up" + assert_selector "input[type=password][autocomplete=new-password]" + end + + context "when logged in" do + setup do + @user = create(:user) + sign_in_as(@user) + + get :new + end + + should redirect_to("root") { root_path } + end end context "on POST to create" do context "when email and password are given" do should "create a user" do post :create, params: { user: { email: "foo@bar.com", password: PasswordHelpers::SECURE_TEST_PASSWORD } } + assert User.find_by(email: "foo@bar.com") end end context "when missing a parameter" do - should "raises parameter missing" do + should "reports validation error" do assert_no_changes -> { User.count } do - post :create + post :create, params: { user: { password: PasswordHelpers::SECURE_TEST_PASSWORD } } end assert_response :ok assert page.has_content?("Email address is not a valid email") @@ -29,34 +48,51 @@ class UsersControllerTest < ActionController::TestCase context "when extra parameters given" do should "create a user if parameters are ok" do - post :create, params: { user: { email: "foo@bar.com", password: PasswordHelpers::SECURE_TEST_PASSWORD, handle: "foo" } } - assert_equal "foo", User.where(email: "foo@bar.com").pluck(:handle).first + post :create, params: { user: { email: "foo@bar.com", password: PasswordHelpers::SECURE_TEST_PASSWORD, handle: "foo", public_email: "true" } } + + user = User.find_by!(email: "foo@bar.com") + + assert_equal "foo", user.handle + assert_predicate user, :public_email? end should "create a user but dont assign not valid parameters" do post :create, params: { user: { email: "foo@bar.com", password: "secret", api_key: "nonono" } } - assert_not_equal "nonono", User.where(email: "foo@bar.com").pluck(:api_key).first + + assert_not_equal "nonono", User.where(email: "foo@bar.com").pick(:api_key) end end context "confirmation mail" do setup do post :create, params: { user: { email: "foo@bar.com", password: PasswordHelpers::SECURE_TEST_PASSWORD, handle: "foo" } } - Delayed::Worker.new.work_off end should "set email_confirmation_token" do user = User.find_by_name("foo") + assert_not_nil user.confirmation_token end should "deliver confirmation mail" do - refute ActionMailer::Base.deliveries.empty? + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob + + refute_empty ActionMailer::Base.deliveries email = ActionMailer::Base.deliveries.last + assert_equal ["foo@bar.com"], email.to assert_equal ["no-reply@mailer.rubygems.org"], email.from assert_equal "Please confirm your email address with RubyGems.org", email.subject end + + should "not deliver confirmation mail when token is removed meanwhile" do + user = User.find_by_name("foo") + user.update(confirmation_token: nil) + + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob + + assert_empty ActionMailer::Base.deliveries + end end end end diff --git a/test/functional/versions_controller_test.rb b/test/functional/versions_controller_test.rb index 97549b4cca6..fcdbca198a3 100644 --- a/test/functional/versions_controller_test.rb +++ b/test/functional/versions_controller_test.rb @@ -41,8 +41,8 @@ class VersionsControllerTest < ActionController::TestCase should "render information about versions" do @versions.each do |v| assert_select "entry > title", count: 1, text: v.to_title - assert_select "entry > link[href='#{rubygem_version_url(v.rubygem, v.slug)}']", count: 1 - assert_select "entry > id", count: 1, text: rubygem_version_url(v.rubygem, v.slug) + assert_select "entry > link[href='#{rubygem_version_url(v.rubygem.slug, v.slug)}']", count: 1 + assert_select "entry > id", count: 1, text: rubygem_version_url(v.rubygem.slug, v.slug) # assert_select "entry > updated", :count => @versions.count, :text => v.created_at.iso8601 end end @@ -55,6 +55,7 @@ class VersionsControllerTest < ActionController::TestCase end should respond_with :success + should "show not hosted notice" do assert page.has_content?("This gem is not currently hosted") end @@ -63,6 +64,80 @@ class VersionsControllerTest < ActionController::TestCase end end + context "on GET to index - pluralization" do + context "with one version" do + setup do + rubygem = create(:rubygem) + create(:version, number: "1.1.2", rubygem: rubygem) + get :index, params: { rubygem_id: rubygem.name } + end + + should "use the singular version" do + assert_select ".t-list__heading", text: /1 version\b/, count: 1 + end + end + + context "with two versions" do + setup do + rubygem = create(:rubygem) + create(:version, number: "1.1.2", rubygem: rubygem) + create(:version, number: "1.1.3", rubygem: rubygem) + get :index, params: { rubygem_id: rubygem.name } + end + + should "use the plural version" do + assert_select ".t-list__heading", text: /2 versions\b/, count: 1 + end + end + end + + context "on GET to index with imported versions" do + setup do + @built_at = Date.parse("2000-01-01") + rubygem = create(:rubygem) + create(:version, number: "1.1.2", rubygem: rubygem, created_at: Version::RUBYGEMS_IMPORT_DATE, built_at: @built_at) + get :index, params: { rubygem_id: rubygem.name } + end + + should respond_with :success + + should "show imported version number with an superscript asterisk and a tooltip" do + tooltip_text = <<~NOTICE.squish + This gem version was imported to RubyGems.org on July 25, 2009. + The date displayed was specified by the author in the gemspec. + NOTICE + + assert_select ".gem__version__date", text: "- January 01, 2000*", count: 1 do |elements| + version = elements.first + + assert_equal(tooltip_text, version["data-tooltip"]) + end + + assert_select ".gem__version__date sup", text: "*", count: 1 + end + end + + context "on GET to index" do + setup do + @rubygem = create(:rubygem) + create(:version, number: "1.1.2", rubygem: @rubygem) + end + + should "get paginated result" do + # first page includes the only version + get :index, params: { rubygem_id: @rubygem.name } + + assert_response :success + assert page.has_content?("1.1.2") + + # second page does not include the only version + get :index, params: { rubygem_id: @rubygem.name, page: 2 } + + assert_response :success + refute page.has_content?("1.1.2") + end + end + context "On GET to show" do setup do @latest_version = create(:version, built_at: 1.week.ago, created_at: 1.day.ago) @@ -74,6 +149,7 @@ class VersionsControllerTest < ActionController::TestCase end should respond_with :success + should "render info about the gem" do assert page.has_content?(@rubygem.name) end @@ -85,8 +161,9 @@ class VersionsControllerTest < ActionController::TestCase assert page.has_content?(version.number) end end + should "render the checksum version" do - assert page.has_content?(@latest_version.sha256_hex) + assert page.has_field?("gem_sha_256_checksum", with: @latest_version.sha256_hex) end end @@ -99,13 +176,15 @@ class VersionsControllerTest < ActionController::TestCase end should respond_with :success + should "show yanked notice" do assert page.has_content?("This version has been yanked") end should "render other versions" do assert page.has_content?("Versions") assert page.has_content?(@version.number) - css = "small:contains('#{@version.created_at.to_date.to_formatted_s(:long)}')" + css = "small:contains('#{@version.authored_at.to_date.to_fs(:long)}')" + assert page.has_css?(css) end should "renders owner gems overview link" do diff --git a/test/functional/webauthn_credentials_controller_test.rb b/test/functional/webauthn_credentials_controller_test.rb new file mode 100644 index 00000000000..2bac0608815 --- /dev/null +++ b/test/functional/webauthn_credentials_controller_test.rb @@ -0,0 +1,365 @@ +require "test_helper" + +class WebauthnCredentialsControllerTest < ActionController::TestCase + include ActiveJob::TestHelper + + context "#create" do + context "when logged out" do + setup do + post :create + end + + should redirect_to :sign_in + end + + context "when logged in" do + setup do + @user = create(:user) + sign_in_as @user + post :create + @json = JSON.parse(response.body) + end + + should "return the user id" do + assert_not_nil @json["user"]["id"] + end + + should "return the challenge" do + assert_not_nil @json["challenge"] + end + + should "return no excluded credentials" do + assert_empty @json["excludeCredentials"] + end + + should "set the challenge in the session" do + assert_not_nil session[:webauthn_registration]["challenge"] + end + end + + context "when having existing credentials" do + setup do + @user = create(:user) + create_list(:webauthn_credential, 3, user: @user) + sign_in_as @user + post :create + @json = JSON.parse(response.body) + end + + should "return the user id" do + assert_not_nil @json["user"]["id"] + end + + should "return the challenge" do + assert_not_nil @json["challenge"] + end + + should "return excluded credentials" do + assert_equal 3, @json["excludeCredentials"].size + + @json["excludeCredentials"].each do |credential| + assert_not_nil credential["id"] + assert_not_nil credential["type"] + end + end + + should "set the challenge in the session" do + assert_not_nil session[:webauthn_registration]["challenge"] + end + end + end + + context "#callback" do + context "when logged out" do + setup do + post :callback + end + + should redirect_to :sign_in + end + + context "when logged in" do + setup do + @user = create(:user) + sign_in_as @user + post :create + end + + context "when correctly verifying a challenge" do + setup do + @nickname = SecureRandom.hex + challenge = JSON.parse(response.body)["challenge"] + origin = WebAuthn.configuration.origin + client = WebAuthn::FakeClient.new(origin, encoding: false) + + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + post( + :callback, + params: { + credentials: WebauthnHelpers.create_result( + client: client, + challenge: challenge + ), + webauthn_credential: { nickname: @nickname } + }, + format: :json + ) + end + end + + should "set redirect url to recovery codes page" do + json = JSON.parse(response.body) + + assert_equal recovery_multifactor_auth_url, json["redirect_url"] + end + + should "display a flash message" do + assert_equal "You have successfully registered a security device.", flash[:notice] + end + + should "create the webauthn credential" do + assert_equal @nickname, @user.webauthn_credentials.last.nickname + assert_equal 1, @user.webauthn_credentials.count + end + + should "set the users mfa_level to 'ui_and_api'" do + assert_equal "ui_and_api", @user.reload.mfa_level + end + + should "generate recovery codes" do + assert_equal 10, @user.reload.mfa_hashed_recovery_codes.count + end + + should "set session show_recovery_codes to true" do + assert session[:show_recovery_codes] + end + + should "deliver webauthn credential added email" do + assert_equal 1, ActionMailer::Base.deliveries.size + email = ActionMailer::Base.deliveries.last + + assert_equal [@user.email], email.to + assert_equal ["no-reply@mailer.rubygems.org"], email.from + assert_equal "New security device added on RubyGems.org", email.subject + end + end + + context "when nickname is not present" do + setup do + @nickname = "" + challenge = JSON.parse(response.body)["challenge"] + origin = WebAuthn.configuration.origin + client = WebAuthn::FakeClient.new(origin, encoding: false) + post( + :callback, + params: { + credentials: WebauthnHelpers.create_result( + client: client, + challenge: challenge + ), + webauthn_credential: { nickname: @nickname } + }, + format: :json + ) + end + + should respond_with :unprocessable_content + end + + context "when challenge is incorrect" do + setup do + @nickname = SecureRandom.hex + challenge = SecureRandom.hex + origin = WebAuthn.configuration.origin + client = WebAuthn::FakeClient.new(origin, encoding: false) + post( + :callback, + params: { + credentials: WebauthnHelpers.create_result( + client: client, + challenge: challenge + ), + webauthn_credential: { nickname: @nickname } + }, + format: :json + ) + end + + setup { subject } + + should respond_with :unprocessable_content + end + + context "when totp is already registered on the user" do + setup do + @seed = ROTP::Base32.random_base32 + @user.enable_totp!(@seed, :ui_and_gem_signin) + + @nickname = SecureRandom.hex + @challenge = JSON.parse(response.body)["challenge"] + origin = WebAuthn.configuration.origin + @client = WebAuthn::FakeClient.new(origin, encoding: false) + end + + should "not change the users mfa_level or recovery codes" do + assert_no_changes -> { [@user.reload.mfa_level, @user.reload.mfa_hashed_recovery_codes.count] } do + post( + :callback, + params: { + credentials: WebauthnHelpers.create_result( + client: @client, + challenge: @challenge + ), + webauthn_credential: { nickname: @nickname } + }, + format: :json + ) + end + + assert_nil @controller.session[:show_recovery_codes] + end + + should "set redirect url to edit settings page" do + post( + :callback, + params: { + credentials: WebauthnHelpers.create_result( + client: @client, + challenge: @challenge + ), + webauthn_credential: { nickname: @nickname } + }, + format: :json + ) + + json = JSON.parse(response.body) + + assert_equal edit_settings_url, json["redirect_url"] + end + end + end + end + + context "#destroy" do + context "when the user has no other webauthn credentials and no otp" do + setup do + @user = create(:user) + @credential = create(:webauthn_credential, user: @user) + sign_in_as @user + + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + delete :destroy, params: { id: @credential.id } + end + end + + should "set the users mfa_level to disabled" do + assert_equal "disabled", @user.reload.mfa_level + end + + should "remove recovery codes" do + assert_empty @user.reload.mfa_hashed_recovery_codes + end + + should "destroy the webauthn credential" do + assert_equal 0, @user.webauthn_credentials.count + end + + should "set success notice flash" do + assert_equal "Credential deleted", flash[:notice] + end + + should "set failure notice flash if destroy fails" do + @user.stubs(:webauthn_credentials).returns @credential + WebauthnCredential.any_instance.stubs(:find).returns @credential + @credential.stubs(:destroy).returns false + + delete :destroy, params: { id: @credential.id } + + refute_nil flash[:error] + end + + should "deliver webauthn credential removed email" do + assert_equal 1, ActionMailer::Base.deliveries.size + email = ActionMailer::Base.deliveries.last + + assert_equal [@user.email], email.to + assert_equal ["no-reply@mailer.rubygems.org"], email.from + assert_equal "Security device removed on RubyGems.org", email.subject + end + + should redirect_to :edit_settings + end + + context "when the user has other webauthn credentials but no otp" do + setup do + @user = create(:user) + @credential1 = create(:webauthn_credential, user: @user) + @credential2 = create(:webauthn_credential, user: @user) + sign_in_as @user + + @user_hashed_recovery_codes = @user.mfa_hashed_recovery_codes + + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + delete :destroy, params: { id: @credential1.id } + end + end + + should "not change the users mfa_level" do + assert_equal "ui_and_api", @user.reload.mfa_level + end + + should "not change the users recovery codes" do + assert_equal @user_hashed_recovery_codes, @user.reload.mfa_hashed_recovery_codes + end + end + + context "when the user has one webauthn credential and totp" do + setup do + @user = create(:user) + @seed = ROTP::Base32.random_base32 + @user.enable_totp!(@seed, :ui_and_api) + @credential = create(:webauthn_credential, user: @user) + sign_in_as @user + + @user_hashed_recovery_codes = @user.mfa_hashed_recovery_codes + + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + delete :destroy, params: { id: @credential.id } + end + end + + should "not change the users mfa_level" do + assert_equal "ui_and_api", @user.reload.mfa_level + end + + should "not change the users recovery codes" do + assert_equal @user_hashed_recovery_codes, @user.reload.mfa_hashed_recovery_codes + end + end + + context "when the user has other webauthn credentials and an otp" do + setup do + @user = create(:user) + @seed = ROTP::Base32.random_base32 + @user.enable_totp!(@seed, :ui_and_api) + @credential1 = create(:webauthn_credential, user: @user) + @credential2 = create(:webauthn_credential, user: @user) + sign_in_as @user + + @user_hashed_recovery_codes = @user.mfa_hashed_recovery_codes + + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + delete :destroy, params: { id: @credential1.id } + end + end + + should "not change the users mfa_level" do + assert_equal "ui_and_api", @user.reload.mfa_level + end + + should "not change the users recovery codes" do + assert_equal @user_hashed_recovery_codes, @user.reload.mfa_hashed_recovery_codes + end + end + end +end diff --git a/test/functional/webauthn_verifications_controller_test.rb b/test/functional/webauthn_verifications_controller_test.rb new file mode 100644 index 00000000000..49ac1361fc0 --- /dev/null +++ b/test/functional/webauthn_verifications_controller_test.rb @@ -0,0 +1,389 @@ +require "test_helper" + +# Not to be confused with Api::V1::WebauthnVerificationsControllerTest. This is for the UI. + +class WebauthnVerificationsControllerTest < ActionController::TestCase + context "#prompt" do + context "when given an invalid webauthn token" do + setup do + @user = create(:user) + get :prompt, params: { webauthn_token: "not_valid1234", port: 1 } + end + + should "return a 404" do + assert_response :not_found + end + end + + context "when the webauthn token has expired" do + setup do + @user = create(:user) + @token = create(:webauthn_verification, user: @user, path_token_expires_at: 1.second.ago).path_token + get :prompt, params: { webauthn_token: @token } + end + + should respond_with :redirect + should redirect_to("the homepage") { root_url } + + should "say the token is consumed or expired" do + assert_equal "The token in the link you used has either expired or been used already.", flash[:alert] + end + end + + context "when given a valid webauthn token param" do + setup do + @user = create(:user) + @token = create(:webauthn_verification, user: @user).path_token + end + + context "with webauthn devices enabled" do + setup do + create(:webauthn_credential, user: @user) + get :prompt, params: { webauthn_token: @token, port: 1 } + end + + should respond_with :success + should "set webauthn authentication" do + assert_not_nil session[:webauthn_authentication]["challenge"] + assert_equal "1", session[:webauthn_authentication]["port"] + end + + should "render the verification page" do + assert page.has_content?("Authenticate with Security Device") + end + + should "set the user" do + assert page.has_content?(@user.name) + end + + should "provide the verification button" do + assert page.has_button?("Authenticate") + end + end + + context "when no port is given" do + setup do + create(:webauthn_credential, user: @user) + get :prompt, params: { webauthn_token: @token } + end + + should redirect_to("the homepage") { root_url } + + should "display error that no port was given" do + assert_equal "No port provided. Please try again.", flash[:alert] + end + end + + context "with no webauthn devices enabled" do + setup do + get :prompt, params: { webauthn_token: @token, port: 1 } + end + + should respond_with :redirect + should redirect_to("the homepage") { root_url } + + should "display error that user has no webauthn devices enabled" do + assert_equal "You don't have any security devices enabled", flash[:alert] + end + end + end + end + + context "#authenticate" do + setup do + @user = create(:user) + @webauthn_credential = create(:webauthn_credential, user: @user) + @port = 1 + @verification_created_at = Time.utc(2023, 1, 1, 0, 0, 0) + travel_to @verification_created_at do + @verification = create(:webauthn_verification, user: @user, otp: nil, otp_expires_at: nil) + @token = @verification.path_token + get :prompt, params: { webauthn_token: @token, port: @port } + end + end + + context "when verifying the challenge" do + setup do + @challenge = session[:webauthn_authentication]["challenge"] + @origin = WebAuthn.configuration.origin + @rp_id = URI.parse(@origin).host + @client = WebAuthn::FakeClient.new(@origin, encoding: false) + WebauthnHelpers.create_credential( + webauthn_credential: @webauthn_credential, + client: @client + ) + @generated_time = Time.utc(2023, 1, 1, 0, 0, 3) + authenticate_request(time: @generated_time) + @verification.reload + end + + should redirect_to("localhost with provided port and verification code") { "http://localhost:#{@port}?code=#{@verification.otp}" } + + should "set OTP with expiry" do + assert_equal 16, @user.webauthn_verification.otp.length + assert_equal @generated_time + 2.minutes, @user.webauthn_verification.otp_expires_at + end + + should "expire the path token by setting its expiry to 1 second prior" do + verification = WebauthnVerification.find_by!(path_token: @token) + + assert_equal Time.utc(2023, 1, 1, 0, 0, 2), verification.path_token_expires_at + end + + should "set show_webauthn_status in session" do + assert @controller.session[:show_webauthn_status] + end + end + + context "when verifying the challenge with safari" do + setup do + @challenge = session[:webauthn_authentication]["challenge"] + @origin = WebAuthn.configuration.origin + @rp_id = URI.parse(@origin).host + @client = WebAuthn::FakeClient.new(@origin, encoding: false) + WebauthnHelpers.create_credential( + webauthn_credential: @webauthn_credential, + client: @client + ) + Browser::Unknown.any_instance.stubs(:safari?).returns true + authenticate_request + end + + should "render success" do + assert_equal "success", response.body + end + + should "set show_webauthn_status in session" do + assert @controller.session[:show_webauthn_status] + end + end + + context "when not providing credentials" do + setup do + travel_to Time.utc(2023, 1, 1, 0, 0, 3) do + post( + :authenticate, + params: { + webauthn_token: @token + }, + format: :json + ) + end + @verification.reload + end + + should respond_with :unauthorized + + should "return error message" do + assert_equal "Credentials required", response.body + end + + should "not expire the path token" do + verification = WebauthnVerification.find_by!(path_token: @token) + + assert_equal Time.utc(2023, 1, 1, 0, 2, 0), verification.path_token_expires_at + end + + should "not generate OTP" do + assert_nil @verification.otp + assert_nil @verification.otp_expires_at + end + + should "set show_webauthn_status in session" do + assert @controller.session[:show_webauthn_status] + end + end + + context "when providing wrong credentials" do + setup do + @wrong_challenge = "16b8e11ea1b46abc64aea3ecdac1c418" + @origin = WebAuthn.configuration.origin + @rp_id = URI.parse(@origin).host + @client = WebAuthn::FakeClient.new(@origin, encoding: false) + WebauthnHelpers.create_credential( + webauthn_credential: @webauthn_credential, + client: @client + ) + authenticate_request(challenge: @wrong_challenge) + @verification.reload + end + + should respond_with :unauthorized + + should "return error message" do + assert_equal "WebAuthn::ChallengeVerificationError", response.body + end + + should "not generate OTP" do + assert_nil @verification.otp + assert_nil @verification.otp_expires_at + end + + should "set show_webauthn_status in session" do + assert @controller.session[:show_webauthn_status] + end + end + + context "when given an invalid webauthn token" do + setup do + @wrong_webauthn_token = "pRpwn2mTH2D18t58" + @challenge = session[:webauthn_authentication]["challenge"] + @origin = WebAuthn.configuration.origin + @rp_id = URI.parse(@origin).host + @client = WebAuthn::FakeClient.new(@origin, encoding: false) + WebauthnHelpers.create_credential( + webauthn_credential: @webauthn_credential, + client: @client + ) + authenticate_request(token: @wrong_webauthn_token) + end + + should respond_with :not_found + + should "say not found" do + assert_equal "Not Found", response.body + end + + should "set show_webauthn_status in session" do + assert @controller.session[:show_webauthn_status] + end + end + + context "when the webauthn token has expired" do + setup do + @challenge = session[:webauthn_authentication]["challenge"] + @origin = WebAuthn.configuration.origin + @rp_id = URI.parse(@origin).host + @client = WebAuthn::FakeClient.new(@origin, encoding: false) + WebauthnHelpers.create_credential( + webauthn_credential: @webauthn_credential, + client: @client + ) + authenticate_request(time: Time.utc(2023, 1, 1, 0, 3, 0)) + end + + should respond_with :unauthorized + + should "say the token is consumed or expired" do + assert_equal "The token in the link you used has either expired or been used already.", response.body + end + + should "set show_webauthn_status in session" do + assert @controller.session[:show_webauthn_status] + end + end + + context "when no port is given" do + setup do + @challenge = session[:webauthn_authentication]["challenge"] + session[:webauthn_authentication]["port"] = nil + @origin = WebAuthn.configuration.origin + @rp_id = URI.parse(@origin).host + @client = WebAuthn::FakeClient.new(@origin, encoding: false) + WebauthnHelpers.create_credential( + webauthn_credential: @webauthn_credential, + client: @client + ) + authenticate_request + @verification.reload + end + + should redirect_to("the homepage") { root_url } + + should "display error that no port was given" do + assert_equal "No port provided. Please try again.", flash[:alert] + end + + should "set show_webauthn_status in session" do + assert @controller.session[:show_webauthn_status] + end + end + end + + context "#successful_verification" do + context "when proceeding authentication request" do + setup do + post( + :authenticate, + params: { + webauthn_token: create(:webauthn_verification).path_token + }, + format: :text + ) + get :successful_verification + end + + should respond_with :success + + should "set the title and body" do + assert_includes response.body, "Success!" + assert_includes response.body, "Please close this browser." + end + + should "clear show_webauthn_status" do + refute @controller.session[:show_webauthn_status] + end + end + + context "when not proceeding authentication request" do + setup do + get :successful_verification + end + + should respond_with :not_found + end + end + + context "#failed_verification" do + context "when proceeding authentication request" do + setup do + post( + :authenticate, + params: { + webauthn_token: create(:webauthn_verification).path_token + }, + format: :text + ) + get :failed_verification + end + + should respond_with :success + + should "set the title and body" do + assert_includes response.body, "Error - Verification Failed" + assert_includes response.body, "Please close this browser and try again." + end + + should "clear show_webauthn_status" do + refute @controller.session[:show_webauthn_status] + end + end + + context "when not proceeding authentication request" do + setup do + get :failed_verification + end + + should respond_with :not_found + end + end + + private + + def authenticate_request(time: @verification_created_at + 3.seconds, token: @token, challenge: @challenge) + travel_to time do + post( + :authenticate, + params: { + credentials: + WebauthnHelpers.get_result( + client: @client, + challenge: challenge + ), + webauthn_token: token + }, + format: :text + ) + end + end +end diff --git a/test/gems/bin_and_img-0.1.0.gem b/test/gems/bin_and_img-0.1.0.gem new file mode 100644 index 00000000000..86127a7263e Binary files /dev/null and b/test/gems/bin_and_img-0.1.0.gem differ diff --git a/test/gems/expired_signature-0.0.0.gem b/test/gems/expired_signature-0.0.0.gem new file mode 100644 index 00000000000..a9966af069f Binary files /dev/null and b/test/gems/expired_signature-0.0.0.gem differ diff --git a/test/gems/mfa-required-1.0.0.gem b/test/gems/mfa-required-1.0.0.gem new file mode 100644 index 00000000000..612295a281f Binary files /dev/null and b/test/gems/mfa-required-1.0.0.gem differ diff --git a/test/gems/valid_signature-0.0.0.gem b/test/gems/valid_signature-0.0.0.gem new file mode 100644 index 00000000000..b8e6328fa09 Binary files /dev/null and b/test/gems/valid_signature-0.0.0.gem differ diff --git a/test/gems/valid_signature_tampered-0.0.1.gem b/test/gems/valid_signature_tampered-0.0.1.gem new file mode 100644 index 00000000000..72bf882b087 Binary files /dev/null and b/test/gems/valid_signature_tampered-0.0.1.gem differ diff --git a/test/helpers/admin_helpers.rb b/test/helpers/admin_helpers.rb new file mode 100644 index 00000000000..5f23874d61b --- /dev/null +++ b/test/helpers/admin_helpers.rb @@ -0,0 +1,11 @@ +module AdminHelpers + extend ActiveSupport::Concern + + included do + def admin_sign_in_as(admin_user) + cookie_jar = ActionDispatch::Request.new(Rails.application.env_config.deep_dup).cookie_jar + cookie_jar.encrypted["rubygems_admin_oauth_github_user"] = admin_user.id + cookies["rubygems_admin_oauth_github_user"] = cookie_jar["rubygems_admin_oauth_github_user"] + end + end +end diff --git a/test/helpers/adoption_helpers.rb b/test/helpers/adoption_helpers.rb new file mode 100644 index 00000000000..6df90a7500f --- /dev/null +++ b/test/helpers/adoption_helpers.rb @@ -0,0 +1,9 @@ +module AdoptionHelpers + def visit_rubygem_adoptions_path(rubygem, user) + visit rubygem_adoptions_path(rubygem.slug, as: user) + return unless page.has_css? "#verify_password_password" + + fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD + click_button "Confirm" + end +end diff --git a/test/helpers/api_policy_helpers.rb b/test/helpers/api_policy_helpers.rb new file mode 100644 index 00000000000..0d8ab5d7a7c --- /dev/null +++ b/test/helpers/api_policy_helpers.rb @@ -0,0 +1,109 @@ +require_relative "policy_helpers" + +module ApiPolicyHelpers + extend ActiveSupport::Concern + include PolicyHelpers + + class_methods do + def should_require_scope(scope, action) + context "requires #{scope} scope" do + should "deny ApiKey without scope" do + refute_authorized key_without_scope(scope), action + end + + should "allow ApiKey with scope" do + assert_authorized key_with_scope(scope), action + end + end + end + + def should_require_rubygem_scope(scope, action) + context "requires #{scope} and matching rubygem" do + should "deny ApiKey with rubygem without scope" do + refute_authorized key_without_scope(scope, rubygem: @rubygem), action + end + + should "deny ApiKey with scope but wrong rubygem" do + refute_authorized key_with_scope(scope, rubygem: create(:rubygem, owners: [@owner])), action + end + + should "allow ApiKey with scope and rubygem" do + assert_authorized key_with_scope(scope, rubygem: @rubygem), action + end + end + end + + def should_require_user_key(scope, action) + context "requires ApiKey owned by a user" do + should "deny ApiKey not owned by a user" do + refute_authorized trusted_publisher_key(scope), action, I18n.t(:api_key_forbidden) + end + + should "allow ApiKey owned by a user" do + assert_authorized key_with_scope(scope), action + end + end + end + + def should_require_mfa(scope, action) + context "mfa required" do + setup do + GemDownload.increment(Rubygem::MFA_REQUIRED_THRESHOLD + 1, rubygem_id: @rubygem.id) + @rubygem.reload + end + + should "deny ApiKey with owner.mfa_required_not_yet_enabled?" do + assert_predicate @owner, :mfa_required_not_yet_enabled? + refute_authorized key_with_scope(scope), action, I18n.t("multifactor_auths.api.mfa_required_not_yet_enabled") + end + + should "deny ApiKey with owner.mfa_required_weak_level_enabled?" do + @owner.enable_totp!(ROTP::Base32.random_base32, :ui_only) + + assert_predicate @owner, :mfa_required_weak_level_enabled? + refute_authorized key_with_scope(scope), action, I18n.t("multifactor_auths.api.mfa_required_weak_level_enabled") + end + + should "allow ApiKey with strong level mfa" do + @owner.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + + assert_predicate @owner, :strong_mfa_level? + assert_authorized key_with_scope(scope), action + end + end + end + + def should_delegate_to_policy(scope, action, policy_class) + context "delegates to #{policy_class}##{action}" do + should "allow if the #{policy_class} allows #{action}" do + policy_class.any_instance.stubs(action => true) + + assert_authorized key_with_scope(scope), action + end + + should "deny if the #{policy_class} denies #{action}" do + policy_class.any_instance.stubs(action => false, :error => "error") + + refute_authorized key_with_scope(scope), action, "error" + end + end + end + end + + def trusted_publisher_key(scope) + create(:api_key, :trusted_publisher, scopes: [scope]) + end + + def key_without_scope(scopes, **) + scopes = (ApiKey::APPLICABLE_GEM_API_SCOPES - Array.wrap(scopes)).sample(2) + key_with_scope(scopes, **) + end + + def key_with_scope(scopes, owner: @owner, **) + create(:api_key, owner:, scopes: Array.wrap(scopes), **) + end + + def refute_authorized(actor, action, message = I18n.t(:api_key_insufficient_scope)) + super + end +end diff --git a/test/helpers/avo_helpers.rb b/test/helpers/avo_helpers.rb new file mode 100644 index 00000000000..1927d7b4bf1 --- /dev/null +++ b/test/helpers/avo_helpers.rb @@ -0,0 +1,24 @@ +module AvoHelpers + def avo_sign_in_as(user) + OmniAuth.config.mock_auth[:github] = OmniAuth::AuthHash.new( + provider: "github", + uid: "1", + credentials: { + token: user.oauth_token, + expires: false + }, + info: { + name: user.login + } + ) + + @ip_address = create(:ip_address, ip_address: "127.0.0.1") + + stub_github_info_request(user.info_data) + + visit avo.root_path + click_button "Log in with GitHub" + + page.assert_text user.login + end +end diff --git a/test/helpers/email_helpers.rb b/test/helpers/email_helpers.rb index ad3444e6f63..a6c0804d135 100644 --- a/test/helpers/email_helpers.rb +++ b/test/helpers/email_helpers.rb @@ -1,11 +1,6 @@ module EmailHelpers def last_email_link - Delayed::Worker.new.work_off - confirmation_link - end - - def confirmation_link_from(job) - Delayed::Worker.new.run(job) + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob confirmation_link end @@ -18,8 +13,9 @@ def mails_count end def confirmation_link - body = last_email.body.decoded.to_s - link = %r{http://localhost/email_confirmations([^";]*)}.match(body) + refute_empty ActionMailer::Base.deliveries + body = last_email.parts[1].body.decoded.to_s + link = %r{http://localhost(?::\d+)?/email_confirmations([^";]*)}.match(body) link[0] end end diff --git a/test/helpers/es_helper.rb b/test/helpers/es_helper.rb index 9ecf5c35622..47552fd0548 100644 --- a/test/helpers/es_helper.rb +++ b/test/helpers/es_helper.rb @@ -1,13 +1,9 @@ -module ESHelper +module SearchKickHelper def import_and_refresh - Rubygem.import force: true - refresh_index - end + Rubygem.searchkick_reindex - def refresh_index - Rubygem.__elasticsearch__.refresh_index! # wait for indexing to finish - Rubygem.__elasticsearch__.client.cluster.health wait_for_status: "yellow" + Searchkick.client.cluster.health wait_for_status: "yellow" end def es_downloads(id) @@ -21,9 +17,7 @@ def es_version_downloads(id) end def get_response(id) - refresh_index - Rubygem.__elasticsearch__.client.get index: "rubygems-#{Rails.env}", - type: "rubygem", - id: id + Rubygem.searchkick_index.refresh + Searchkick.client.get index: "rubygems-#{Rails.env}", id: id end end diff --git a/test/helpers/gem_helpers.rb b/test/helpers/gem_helpers.rb index 435647f6652..dd1d9fc5dd1 100644 --- a/test/helpers/gem_helpers.rb +++ b/test/helpers/gem_helpers.rb @@ -3,8 +3,8 @@ def gem_specification_from_gem_fixture(name) Gem::Package.new(File.join("test", "gems", "#{name}.gem")).spec end - def gem_file(name = "test-0.0.0.gem") - Rails.root.join("test", "gems", name.to_s).open + def gem_file(name = "test-0.0.0.gem", &) + Rails.root.join("test", "gems", name.to_s).open("rb", &) end def build_gemspec(gemspec) @@ -13,8 +13,29 @@ def build_gemspec(gemspec) end end - def build_gem(name, version, summary = "Gemcutter", platform = "ruby", &block) - build_gemspec(new_gemspec(name, version, summary, platform, &block)) + def build_gem(name, version, summary = "Gemcutter", platform = "ruby", &) + build_gemspec(new_gemspec(name, version, summary, platform, &)) + end + + def build_gem_raw(file_name:, spec:, contents_writer: nil) + package = Gem::Package.new file_name + + File.open(file_name, "wb") do |file| + Gem::Package::TarWriter.new(file) do |gem| + gem.add_file "metadata.gz", 0o444 do |io| + package.gzip_to(io) do |gz_io| + gz_io.write spec + end + end + gem.add_file "data.tar.gz", 0o444 do |io| + package.gzip_to io do |gz_io| + Gem::Package::TarWriter.new gz_io do |data_tar| + contents_writer[data_tar] if contents_writer + end + end + end + end + end end def new_gemspec(name, version, summary, platform, extra_args = {}) diff --git a/test/helpers/oauth_helpers.rb b/test/helpers/oauth_helpers.rb new file mode 100644 index 00000000000..6c6c0c39947 --- /dev/null +++ b/test/helpers/oauth_helpers.rb @@ -0,0 +1,11 @@ +module OauthHelpers + def stub_github_info_request(info_data) + stub_request(:post, "https://api.github.com/graphql") + .with(body: { query: GitHubOAuthable::INFO_QUERY, variables: { organization_name: "rubygems" } }.to_json) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(data: info_data) + ) + end +end diff --git a/test/helpers/policy_helpers.rb b/test/helpers/policy_helpers.rb new file mode 100644 index 00000000000..a5e3871543e --- /dev/null +++ b/test/helpers/policy_helpers.rb @@ -0,0 +1,17 @@ +module PolicyHelpers + extend ActiveSupport::Concern + + def assert_authorized(actor, action) + policy = policy!(actor) + + assert_predicate policy, action + assert_nil policy.error + end + + def refute_authorized(actor, action, message = nil) + policy = policy!(actor) + + refute_predicate policy, action + assert_equal message.chomp, policy.error&.chomp if message + end +end diff --git a/test/helpers/rate_limit_helpers.rb b/test/helpers/rate_limit_helpers.rb index 23c570c7b18..fba31c815f2 100644 --- a/test/helpers/rate_limit_helpers.rb +++ b/test/helpers/rate_limit_helpers.rb @@ -60,6 +60,10 @@ def stay_under_email_limit_for(scope) update_limit_for("#{scope}:#{@user.email}", under_email_limit) end + def stay_under_ownership_request_limit_for(scope) + update_limit_for("#{scope}:#{@user.email}", under_email_limit, Rack::Attack::REQUEST_LIMIT_PERIOD) + end + def stay_under_push_limit_for(scope) under_push_limit = (Rack::Attack::PUSH_LIMIT * 0.5).to_i update_limit_for("#{scope}:#{@user.email}", under_push_limit) @@ -69,18 +73,31 @@ def stay_under_exponential_limit(scope) Rack::Attack::EXP_BACKOFF_LEVELS.each do |level| under_backoff_limit = (Rack::Attack::EXP_BASE_REQUEST_LIMIT * level) - 1 throttle_level_key = "#{scope}/#{level}:#{@ip_address}" - under_backoff_limit.times { Rack::Attack.cache.count(throttle_level_key, exp_base_limit_period**level) } + update_limit_for(throttle_level_key, under_backoff_limit, exp_base_limit_period**level) end end def update_limit_for(key, limit, period = limit_period) + key = Rack::Attack.throttle_discriminator_normalizer.call(key) limit.times { Rack::Attack.cache.count(key, period) } end def exceed_exponential_limit_for(scope, level) expo_exceeding_limit = exceeding_exp_base_limit * level expo_limit_period = exp_base_limit_period**level - expo_exceeding_limit.times { Rack::Attack.cache.count("#{scope}:#{@ip_address}", expo_limit_period) } + update_limit_for("#{scope}:#{@ip_address}", expo_exceeding_limit, expo_limit_period) + end + + def exceed_exponential_user_limit_for(scope, id, level) + expo_exceeding_limit = exceeding_exp_base_limit * level + expo_limit_period = exp_base_limit_period**level + update_limit_for("#{scope}:#{id}", expo_exceeding_limit, expo_limit_period) + end + + def exceed_exponential_api_key_limit_for(scope, user_display_id, level) + expo_exceeding_limit = exceeding_exp_base_limit * level + expo_limit_period = exp_base_limit_period**level + update_limit_for("#{scope}:#{user_display_id}", expo_exceeding_limit, expo_limit_period) end def encode(username, password) @@ -94,9 +111,13 @@ def expected_retry_after(level) (period - (now % period)).to_s end + def exceed_ownership_request_limit_for(scope) + update_limit_for("#{scope}:#{@user.email}", exceeding_email_limit, Rack::Attack::REQUEST_LIMIT_PERIOD) + end + def assert_throttle_at(level) assert_response :too_many_requests assert_equal expected_retry_after(level), @response.headers["Retry-After"] - assert @response.headers["Retry-After"].to_i < @mfa_max_period[level] + assert_operator @response.headers["Retry-After"].to_i, :<=, @mfa_max_period[level] end end diff --git a/test/helpers/webauthn_helpers.rb b/test/helpers/webauthn_helpers.rb new file mode 100644 index 00000000000..184a4ddb756 --- /dev/null +++ b/test/helpers/webauthn_helpers.rb @@ -0,0 +1,45 @@ +module WebauthnHelpers + def self.create_result(client:, challenge: nil) + rp_id = URI.parse(client.origin).host + challenge = + if challenge + Base64.urlsafe_decode64(challenge) + else + SecureRandom.random_bytes(32) + end + + result = client.create( + challenge: challenge, + rp_id: rp_id + ) + + result["rawId"] = Base64.urlsafe_encode64(result["rawId"]) + result["response"]["attestationObject"] = + Base64.urlsafe_encode64(result["response"]["attestationObject"]) + result["response"]["clientDataJSON"] = + Base64.urlsafe_encode64(result["response"]["clientDataJSON"]) + result + end + + def self.create_credential(webauthn_credential:, client:) + credential = create_result(client: client) + response = WebAuthn::Credential.from_create(credential) + webauthn_credential.update!( + external_id: response.id, + public_key: response.public_key, + sign_count: response.sign_count + ) + end + + def self.get_result(client:, challenge:) + result = client.get(challenge: Base64.urlsafe_decode64(challenge)) + result["rawId"] = Base64.urlsafe_encode64(result["rawId"]) + result["response"]["authenticatorData"] = + Base64.urlsafe_encode64(result["response"]["authenticatorData"]) + result["response"]["clientDataJSON"] = + Base64.urlsafe_encode64(result["response"]["clientDataJSON"]) + result["response"]["signature"] = + Base64.urlsafe_encode64(result["response"]["signature"]) + result + end +end diff --git a/test/integration/admin/good_job_test.rb b/test/integration/admin/good_job_test.rb new file mode 100644 index 00000000000..3dad0e33c84 --- /dev/null +++ b/test/integration/admin/good_job_test.rb @@ -0,0 +1,20 @@ +require "test_helper" + +class Admin::GoodJobTest < ActionDispatch::IntegrationTest + include AdminHelpers + + test "viewing good job admin dashboard" do + get "/admin/good_job" + + assert_response :success + page.assert_text "Log in with GitHub" + + admin = create(:admin_github_user, :is_admin) + admin_sign_in_as admin + + get "/admin/good_job/jobs" + + assert_response :success + page.assert_text "GoodJob 👍" + end +end diff --git a/test/integration/api/compact_index_test.rb b/test/integration/api/compact_index_test.rb index c993f317211..53a03fcd8d8 100644 --- a/test/integration/api/compact_index_test.rb +++ b/test/integration/api/compact_index_test.rb @@ -3,7 +3,11 @@ class CompactIndexTest < ActionDispatch::IntegrationTest def etag(body) - '"' << Digest::MD5.hexdigest(body) << '"' + %("#{Digest::MD5.hexdigest(body)}") + end + + def digest(body) + Digest::SHA256.base64digest(body) end setup do @@ -12,8 +16,8 @@ def etag(body) # another gem rubygem = create(:rubygem, name: "gemA") - dep1 = create(:rubygem, name: "gemA1") - dep2 = create(:rubygem, name: "gemA2") + dep1 = create(:rubygem, name: "gemA1", indexed: true) + dep2 = create(:rubygem, name: "gemA2", indexed: true) # minimal version create(:version, @@ -58,43 +62,58 @@ def etag(body) assert_response :success expected_body = "---\ngemA\ngemA1\ngemA2\ngemB\n" + expected_digest = digest(expected_body) + assert_equal expected_body, @response.body assert_equal etag(expected_body), @response.headers["ETag"] + assert_equal "sha-256=#{expected_digest}", @response.headers["Digest"] + assert_equal "sha-256=:#{expected_digest}:", @response.headers["Repr-Digest"] assert_equal %w[gemA gemA1 gemA2 gemB], Rails.cache.read("names") end test "/names partial response" do get names_path, env: { range: "bytes=15-" } - assert_response 206 + assert_response :partial_content full_body = "---\ngemA\ngemA1\ngemA2\ngemB\n" + expected_digest = digest(full_body) + assert_equal etag(full_body), @response.headers["ETag"] + assert_equal "sha-256=#{expected_digest}", @response.headers["Digest"] + assert_equal "sha-256=:#{expected_digest}:", @response.headers["Repr-Digest"] assert_equal "gemA2\ngemB\n", @response.body end test "/versions includes pre-built file and new gems" do versions_file_location = Rails.application.config.rubygems["versions_file_location"] - file_contents = File.open(versions_file_location).read + file_contents = File.read(versions_file_location) gem_a_match = "gemA 1.0.0 013we2\ngemA 2.0.0 1cf94r\ngemA 1.2.0 13q4es\ngemA 2.1.0 e217fz\n" gem_b_match = "gemB 1.0.0 qw2dwe\n" get versions_path + expected_digest = digest(@response.body) + assert_response :success assert_match file_contents, @response.body assert_match(/#{gem_b_match}#{gem_a_match}/, @response.body) assert_equal etag(@response.body), @response.headers["ETag"] + assert_equal "sha-256=#{expected_digest}", @response.headers["Digest"] + assert_equal "sha-256=:#{expected_digest}:", @response.headers["Repr-Digest"] end test "/versions partial response" do get versions_path full_response_body = @response.body partial_body = "1.0.0 013we2\ngemA 2.0.0 1cf94r\ngemA 1.2.0 13q4es\ngemA 2.1.0 e217fz\n" + expected_digest = digest(full_response_body) get versions_path, env: { range: "bytes=229-" } - assert_response 206 + assert_response :partial_content assert_equal partial_body, @response.body assert_equal etag(full_response_body), @response.headers["ETag"] + assert_equal "sha-256=#{expected_digest}", @response.headers["Digest"] + assert_equal "sha-256=:#{expected_digest}:", @response.headers["Repr-Digest"] end test "/versions updates on gem yank" do @@ -110,15 +129,21 @@ def etag(body) get versions_path full_response_body = @response.body + expected_digest = digest(full_response_body) + get versions_path, env: { range: "bytes=206-" } + assert_equal etag(full_response_body), @response.headers["ETag"] + assert_equal "sha-256=#{expected_digest}", @response.headers["Digest"] + assert_equal "sha-256=:#{expected_digest}:", @response.headers["Repr-Digest"] assert_equal expected, @response.body end test "/version has surrogate key header" do get versions_path + assert_equal "versions", @response.headers["Surrogate-Key"] - assert_equal "max-age=3600", @response.headers["Surrogate-Control"] + assert_equal "max-age=3600, stale-while-revalidate=1800, stale-if-error=1800", @response.headers["Surrogate-Control"] end test "/info with existing gem" do @@ -129,17 +154,21 @@ def etag(body) 1.2.0 |checksum:b5d4045c3f466fa91fe2cc6abe79232a1a57cdf104f7a26e716e0a1e2789df78,ruby:>= 2.0.0,rubygems:>1.9 2.1.0 gemA1:= 1.0.0,gemA2:= 1.0.0|checksum:b5d4045c3f466fa91fe2cc6abe79232a1a57cdf104f7a26e716e0a1e2789df78,ruby:>= 2.0.0,rubygems:>=2.0 VERSIONS_FILE + expected_digest = digest(expected) get info_path(gem_name: "gemA") assert_response :success assert_equal expected, @response.body assert_equal etag(expected), @response.headers["ETag"] + assert_equal "sha-256=#{expected_digest}", @response.headers["Digest"] + assert_equal "sha-256=:#{expected_digest}:", @response.headers["Repr-Digest"] assert_equal expected, CompactIndex.info(Rails.cache.read("info/gemA")) end test "/info has surrogate key header" do get info_path(gem_name: "gemA") + assert_equal "info/* gem/gemA info/gemA", @response.headers["Surrogate-Key"] end @@ -154,7 +183,7 @@ def etag(body) get info_path(gem_name: "gemA"), env: { range: "bytes=159-" } - assert_response 206 + assert_response :partial_content assert_equal expected[159..], @response.body end @@ -166,22 +195,27 @@ def etag(body) --- 1.0.0 |checksum:b5d4045c3f466fa91fe2cc6abe79232a1a57cdf104f7a26e716e0a1e2789df78,ruby:>= 2.0.0,rubygems:>= 2.6.3 VERSIONS_FILE + expected_digest = digest(expected) get info_path(gem_name: "gemC") assert_response :success assert_equal(expected, @response.body) assert_equal etag(expected), @response.headers["ETag"] + assert_equal "sha-256=#{expected_digest}", @response.headers["Digest"] + assert_equal "sha-256=:#{expected_digest}:", @response.headers["Repr-Digest"] end test "/info with nonexistent gem" do get info_path(gem_name: "donotexist") + assert_response :not_found assert_nil @response.headers["ETag"] end test "/info with gzip" do get info_path(gem_name: "gemA"), env: { "Accept-Encoding" => "gzip" } + assert_response :success assert_equal("gzip", @response.headers["Content-Encoding"]) end @@ -198,10 +232,14 @@ def etag(body) --- 1.0.0 aaab:>= 0,aaab:~> 0.2,bbcc:= 1.0.0|checksum:b5d4045c3f466fa91fe2cc6abe79232a1a57cdf104f7a26e716e0a1e2789df78,ruby:>= 2.0.0,rubygems:>= 2.6.3 VERSIONS_FILE + expected_digest = digest(expected) get info_path(gem_name: "gemB") + assert_response :success assert_equal(expected, @response.body) assert_equal etag(expected), @response.headers["ETag"] + assert_equal "sha-256=#{expected_digest}", @response.headers["Digest"] + assert_equal "sha-256=:#{expected_digest}:", @response.headers["Repr-Digest"] end end diff --git a/test/integration/api/v1/github_secret_scanning_test.rb b/test/integration/api/v1/github_secret_scanning_test.rb new file mode 100644 index 00000000000..2ba90f36c29 --- /dev/null +++ b/test/integration/api/v1/github_secret_scanning_test.rb @@ -0,0 +1,154 @@ +require "test_helper" + +class Api::V1::GitHubSecretScanningTest < ActionDispatch::IntegrationTest + HEADER_KEYID = "GITHUB-PUBLIC-KEY-IDENTIFIER".freeze + HEADER_SIGNATURE = "GITHUB-PUBLIC-KEY-SIGNATURE".freeze + + KEYS_RESPONSE_BODY = + { "public_keys" => [ + { + "key_identifier" => "test_key_id", + "is_current" => true + } + ] }.freeze + + context "on POST to revoke" do + setup do + key = OpenSSL::PKey::EC.generate("secp256k1") + @private_key_pem = key.to_pem + @public_key_pem = key.public_to_pem + + h = KEYS_RESPONSE_BODY.dup + h["public_keys"][0]["key"] = @public_key_pem + + stub_request(:get, GitHubSecretScanning::KEYS_URI) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: h.to_json + ) + + @tokens = [ + { "token" => "some_token", "type" => "some_type", "url" => "some_url" } + ] + + @user = create(:user) + end + + context "with no key_id" do + setup do + post revoke_api_v1_api_key_path(@rubygem), + params: {}, + headers: { HEADER_SIGNATURE => "bar" } + end + + should "deny access" do + assert_response :unauthorized + assert_match "Missing GitHub Signature", @response.body + end + end + + context "with no signature" do + setup do + post revoke_api_v1_api_key_path(@rubygem), + params: {}, + headers: { HEADER_KEYID => "foo" } + end + + should "deny access" do + assert_response :unauthorized + assert_match "Missing GitHub Signature", @response.body + end + end + + context "with invalid key_id" do + setup do + post revoke_api_v1_api_key_path(@rubygem), + params: {}, + headers: { HEADER_KEYID => "foo", HEADER_SIGNATURE => "bar" } + end + + should "deny access" do + assert_response :unauthorized + assert_match "Can't fetch public key from GitHub", @response.body + end + end + + context "with invalid signature" do + setup do + signature = sign_body("Hello world!") + post revoke_api_v1_api_key_path(@rubygem), + params: {}, + headers: { HEADER_KEYID => "test_key_id", HEADER_SIGNATURE => Base64.encode64(signature) } + end + + should "deny access" do + assert_response :unauthorized + assert_match "Invalid GitHub Signature", @response.body + end + end + + context "without a valid token" do + setup do + signature = sign_body(JSON.dump(@tokens)) + post revoke_api_v1_api_key_path(@rubygem), + params: @tokens, + headers: { HEADER_KEYID => "test_key_id", HEADER_SIGNATURE => Base64.encode64(signature) }, + as: :json + end + + should "returns success" do + assert_response :success + json = JSON.parse(@response.body)[0] + + assert_equal "false_positive", json["label"] + assert_equal @tokens[0]["type"], json["token_type"] + assert_equal @tokens[0]["token"], json["token_raw"] + end + end + + context "with a valid token" do + setup do + key = "rubygems_#{SecureRandom.hex(24)}" + @api_key = create(:api_key, key: key) + @tokens << { "token" => key, "type" => "rubygems", "url" => "some_url" } + signature = sign_body(JSON.dump(@tokens)) + + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + post revoke_api_v1_api_key_path(@rubygem), + params: @tokens, + headers: { HEADER_KEYID => "test_key_id", HEADER_SIGNATURE => Base64.encode64(signature) }, + as: :json + end + end + + should "returns success and remove the token" do + assert_response :success + + json = JSON.parse(@response.body) + + assert_equal "true_positive", json.last["label"] + assert_equal @tokens.last["token"], json.last["token_raw"] + + assert_predicate @api_key.reload, :expired? + end + + should "delivers an email" do + refute_empty ActionMailer::Base.deliveries + email = ActionMailer::Base.deliveries.last + + assert_equal [@api_key.user.email], email.to + assert_equal ["no-reply@mailer.rubygems.org"], email.from + assert_equal "One of your API keys was revoked on rubygems.org", email.subject + assert_match "some_url", email.body.to_s + end + end + end + + private + + def sign_body(body) + private_key = OpenSSL::PKey::EC.new(@private_key_pem) + private_key.sign(OpenSSL::Digest.new("SHA256"), body) + end +end diff --git a/test/integration/api/v1/oidc/api_key_roles_test.rb b/test/integration/api/v1/oidc/api_key_roles_test.rb new file mode 100644 index 00000000000..242b03ce242 --- /dev/null +++ b/test/integration/api/v1/oidc/api_key_roles_test.rb @@ -0,0 +1,386 @@ +require "test_helper" + +class Api::V1::OIDC::ApiKeyRolesTest < ActionDispatch::IntegrationTest + make_my_diffs_pretty! + + context "on GET to index" do + setup do + @role = create(:oidc_api_key_role) + @user = @role.user + @user_api_key = "12323" + @api_key = create(:api_key, owner: @user, key: @user_api_key) + end + + should "return the user's roles" do + get api_v1_oidc_api_key_roles_path, + params: {}, + headers: { "HTTP_AUTHORIZATION" => @user_api_key } + + assert_response :success + assert_equal [ + { + "id" => @role.id, + "token" => @role.token, + "oidc_provider_id" => @role.oidc_provider_id, + "user_id" => @user.id, + "api_key_permissions" => { "scopes" => ["push_rubygem"], "valid_for" => 1800, "gems" => nil }, + "name" => @role.name, + "access_policy" => { "statements" => [ + { "effect" => "allow", + "principal" => { "oidc" => @role.provider.issuer }, + "conditions" => [{ + "operator" => "string_equals", + "claim" => "sub", + "value" => "repo:segiddins/oidc-test:ref:refs/heads/main" + }] } + ] }, + "created_at" => @role.created_at.as_json, + "updated_at" => @role.updated_at.as_json, + "deleted_at" => nil + } + ], response.parsed_body + end + end + + context "on GET to show" do + setup do + @role = create(:oidc_api_key_role) + @user = @role.user + @user_api_key = "12323" + @api_key = create(:api_key, owner: @user, key: @user_api_key) + end + + should "return the user's roles" do + get api_v1_oidc_api_key_role_path(@role.token), + params: {}, + headers: { "HTTP_AUTHORIZATION" => @user_api_key } + + assert_response :success + assert_equal( + { + "id" => @role.id, + "token" => @role.token, + "oidc_provider_id" => @role.oidc_provider_id, + "user_id" => @user.id, + "api_key_permissions" => { "scopes" => ["push_rubygem"], "valid_for" => 1800, "gems" => nil }, + "name" => @role.name, + "access_policy" => { "statements" => [ + { "effect" => "allow", + "principal" => { "oidc" => @role.provider.issuer }, + "conditions" => [{ + "operator" => "string_equals", + "claim" => "sub", + "value" => "repo:segiddins/oidc-test:ref:refs/heads/main" + }] } + ] }, + "created_at" => @role.created_at.as_json, + "updated_at" => @role.updated_at.as_json, + "deleted_at" => nil + }, response.parsed_body + ) + end + end + + def jwt(claims = @claims, key: @pkey) + JSON::JWT.new(claims).sign(key.to_jwk) + end + + context "on POST to assume_role" do + setup do + @pkey = OpenSSL::PKey::RSA.generate(2048) + @role = create(:oidc_api_key_role, provider: build(:oidc_provider, issuer: "https://token.actions.githubusercontent.com", pkey: @pkey)) + @user = @role.user + + @claims = { + "aud" => "https://github.com/segiddins", + "exp" => 1_680_020_837, + "iat" => 1_680_020_537, + "iss" => "https://token.actions.githubusercontent.com", + "jti" => "79685b65-945d-450a-a3d8-a36bcf72c23d", + "nbf" => 1_680_019_937, + "ref" => "refs/heads/main", + "sha" => "04de3558bc5861874a86f8fcd67e516554101e71", + "sub" => "repo:segiddins/oidc-test:ref:refs/heads/main", + "actor" => "segiddins", + "run_id" => "4545231084", + "actor_id" => "1946610", + "base_ref" => "", + "head_ref" => "", + "ref_type" => "branch", + "workflow" => "token", + "event_name" => "push", + "repository" => "segiddins/oidc-test", + "run_number" => "4", + "run_attempt" => "1", + "workflow_ref" => + "segiddins/oidc-test/.github/workflows/token.yml@refs/heads/main", + "workflow_sha" => "04de3558bc5861874a86f8fcd67e516554101e71", + "repository_id" => "620393838", + "job_workflow_ref" => + "segiddins/oidc-test/.github/workflows/token.yml@refs/heads/main", + "job_workflow_sha" => "04de3558bc5861874a86f8fcd67e516554101e71", + "repository_owner" => "segiddins", + "runner_environment" => "github-hosted", + "repository_owner_id" => "1946610", + "repository_visibility" => "public" + } + + travel_to Time.zone.at(1_680_020_830) # after the JWT iat, before the exp + end + + context "with an unknown id" do + should "response not found" do + post assume_role_api_v1_oidc_api_key_role_path(@role.id + 1), + params: {}, + headers: {} + + assert_response :not_found + end + end + + context "with a known id" do + context "with an invalid jwt" do + should "respond not found" do + post assume_role_api_v1_oidc_api_key_role_path(@role), + params: { + jwt: "1#{jwt}" + }, + headers: {} + + assert_response :not_found + assert_empty @user.api_keys + end + end + + context "with a jwt that does not match the jwks" do + should "respond not found" do + @role.provider.jwks.each { _1["n"] += "NO" } + @role.provider.save! + + post assume_role_api_v1_oidc_api_key_role_path(@role), + params: { + jwt: jwt.to_s + }, + headers: {} + + assert_response :not_found + assert_empty @user.api_keys + end + end + + context "with a nbf after the current time" do + should "respond not found" do + @claims["exp"] = Time.now.to_i + 360 + @claims["nbf"] = Time.now.to_i + 60 + + post assume_role_api_v1_oidc_api_key_role_path(@role), + params: { + jwt: jwt.to_s + }, + headers: {} + + assert_response :not_found + assert_empty @user.api_keys + end + end + + context "with a exp before the current time" do + should "respond not found" do + @claims["exp"] = Time.now.to_i - 60 + @claims["nbf"] = Time.now.to_i - 360 + + post assume_role_api_v1_oidc_api_key_role_path(@role), + params: { + jwt: jwt.to_s + }, + headers: {} + + assert_response :not_found + assert_empty @user.api_keys + end + end + + context "with exp before nbf" do + should "respond not found" do + @claims["exp"] = Time.now.to_i - 60 + @claims["nbf"] = Time.now.to_i + 360 + + post assume_role_api_v1_oidc_api_key_role_path(@role), + params: { + jwt: jwt.to_s + }, + headers: {} + + assert_response :not_found + assert_empty @user.api_keys + end + end + + context "with a jwt with the wrong issuer" do + should "respond not found" do + @role.provider.configuration.issuer = "https://example.com" + @role.provider.update!(issuer: "https://example.com") + + post assume_role_api_v1_oidc_api_key_role_path(@role), + params: { + jwt: jwt.to_s + }, + headers: {} + + assert_response :not_found + assert_empty @user.api_keys + end + end + + context "with matching conditions" do + should "return API key" do + @role.access_policy.statements.first.conditions << OIDC::AccessPolicy::Statement::Condition.new( + operator: "string_equals", + claim: "sub", + value: "repo:segiddins/oidc-test:ref:refs/heads/main" + ) + @role.save! + + post assume_role_api_v1_oidc_api_key_role_path(@role.token), + params: { + jwt: jwt.to_s + }, + headers: {} + + assert_response :created + + resp = response.parsed_body + + assert_match(/^rubygems_/, resp["rubygems_api_key"]) + assert_equal({ + "rubygems_api_key" => resp["rubygems_api_key"], + "name" => "#{@role.name}-79685b65-945d-450a-a3d8-a36bcf72c23d", + "scopes" => ["push_rubygem"], + "expires_at" => 30.minutes.from_now + }, resp) + hashed_key = @user.api_keys.sole.hashed_key + + assert_equal hashed_key, Digest::SHA256.hexdigest(resp["rubygems_api_key"]) + end + end + + context "with permissions scoped to a gem" do + should "return API key" do + gem_name = create(:rubygem, owners: [@role.user], number: "1.0.0").name + @role.api_key_permissions.gems = [gem_name] + @role.save! + + post assume_role_api_v1_oidc_api_key_role_path(@role.token), + params: { + jwt: jwt.to_s + }, + headers: {} + + assert_response :created + + resp = response.parsed_body + + assert_match(/^rubygems_/, resp["rubygems_api_key"]) + assert_equal({ + "rubygems_api_key" => resp["rubygems_api_key"], + "name" => "#{@role.name}-79685b65-945d-450a-a3d8-a36bcf72c23d", + "scopes" => ["push_rubygem"], + "expires_at" => 30.minutes.from_now, + "gem" => Rubygem.find_by!(name: gem_name).as_json + }, resp) + hashed_key = @user.api_keys.sole.hashed_key + + assert_equal hashed_key, Digest::SHA256.hexdigest(resp["rubygems_api_key"]) + assert_equal gem_name, @user.api_keys.sole.ownership.rubygem.name + end + end + + context "with mismatched conditions" do + should "return not found" do + @role.access_policy.statements.first.conditions << OIDC::AccessPolicy::Statement::Condition.new( + operator: "string_equals", + claim: "sub", + value: "repo:other/oidc-test:ref:refs/heads/main" + ) + @role.save! + + post assume_role_api_v1_oidc_api_key_role_path(@role.token), + params: { + jwt: jwt.to_s + }, + headers: {} + + assert_response :not_found + assert_empty @user.api_keys + end + end + + context "with a deleted role" do + setup do + @role.update!(deleted_at: Time.current) + end + + should "respond not found" do + post assume_role_api_v1_oidc_api_key_role_path(@role.token), + params: { + jwt: jwt.to_s + }, + headers: {} + + assert_response :not_found + assert_empty @user.api_keys + end + end + + should "return an API token" do + post assume_role_api_v1_oidc_api_key_role_path(@role.token), + params: { + jwt: jwt.to_s + }, + headers: {} + + assert_response :created + + resp = response.parsed_body + + assert_match(/^rubygems_/, resp["rubygems_api_key"]) + assert_equal({ + "rubygems_api_key" => resp["rubygems_api_key"], + "name" => "#{@role.name}-79685b65-945d-450a-a3d8-a36bcf72c23d", + "scopes" => ["push_rubygem"], + "expires_at" => 30.minutes.from_now + }, resp) + hashed_key = @user.api_keys.sole.hashed_key + + assert_equal hashed_key, Digest::SHA256.hexdigest(resp["rubygems_api_key"]) + + oidc_id_token = @role.id_tokens.sole + + assert_equal hashed_key, oidc_id_token.api_key.hashed_key + assert_equal @role.provider, oidc_id_token.provider + assert_equal( + { + "claims" => @claims, + "header" => { + "alg" => "RS256", + "kid" => @pkey.to_jwk[:kid], + "typ" => "JWT" + } + }, + oidc_id_token.jwt + ) + + post assume_role_api_v1_oidc_api_key_role_path(@role.token), + params: { + jwt: jwt.to_s + }, + headers: {} + + assert_response :unprocessable_content + assert_equal({ + "errors" => { "jwt.claims.jti" => ["must be unique"] } + }, response.parsed_body) + end + end + end +end diff --git a/test/integration/api/v1/oidc/id_tokens_test.rb b/test/integration/api/v1/oidc/id_tokens_test.rb new file mode 100644 index 00000000000..60bd7154cc4 --- /dev/null +++ b/test/integration/api/v1/oidc/id_tokens_test.rb @@ -0,0 +1,52 @@ +require "test_helper" + +class Api::V1::OIDC::IdTokensTest < ActionDispatch::IntegrationTest + make_my_diffs_pretty! + + setup do + @role = create(:oidc_api_key_role) + @user = @role.user + @id_token = create(:oidc_id_token, user: @user, api_key_role: @role) + + @user_api_key = "12323" + @api_key = create(:api_key, owner: @user, key: @user_api_key) + end + + context "on GET to index" do + should "return the user's roles" do + get api_v1_oidc_id_tokens_path, + params: {}, + headers: { "HTTP_AUTHORIZATION" => @user_api_key } + + assert_response :success + assert_equal [ + { + "api_key_role_token" => @id_token.api_key_role.token, + "jwt" => { + "claims" => @id_token.jwt["claims"], + "header" => @id_token.jwt["header"] + } + } + ], response.parsed_body + end + end + + context "on GET to show" do + should "return the user's id token" do + get api_v1_oidc_id_token_path(@id_token), + params: {}, + headers: { "HTTP_AUTHORIZATION" => @user_api_key } + + assert_response :success + assert_equal( + { + "api_key_role_token" => @id_token.api_key_role.token, + "jwt" => { + "claims" => @id_token.jwt["claims"], + "header" => @id_token.jwt["header"] + } + }, response.parsed_body + ) + end + end +end diff --git a/test/integration/api/v1/oidc/providers_test.rb b/test/integration/api/v1/oidc/providers_test.rb new file mode 100644 index 00000000000..41c9eac6540 --- /dev/null +++ b/test/integration/api/v1/oidc/providers_test.rb @@ -0,0 +1,33 @@ +require "test_helper" + +class Api::V1::OIDC::ProvidersTest < ActionDispatch::IntegrationTest + make_my_diffs_pretty! + + setup do + @providers = create_list(:oidc_provider, 3) + + @user = create(:user) + @user_api_key = "12323" + @api_key = create(:api_key, owner: @user, key: @user_api_key) + end + + context "on GET to index" do + should "return all providers" do + get api_v1_oidc_providers_path, + params: {}, + headers: { "HTTP_AUTHORIZATION" => @user_api_key } + + assert_response :success + end + end + + context "on GET to show" do + should "return provider" do + get api_v1_oidc_provider_path(@providers[1]), + params: {}, + headers: { "HTTP_AUTHORIZATION" => @user_api_key } + + assert_response :success + end + end +end diff --git a/test/integration/api/v1/oidc/rubygem_trusted_publishers_controller_test.rb b/test/integration/api/v1/oidc/rubygem_trusted_publishers_controller_test.rb new file mode 100644 index 00000000000..64af8414c8c --- /dev/null +++ b/test/integration/api/v1/oidc/rubygem_trusted_publishers_controller_test.rb @@ -0,0 +1,201 @@ +require "test_helper" + +class Api::V1::OIDC::RubygemTrustedPublishersControllerTest < ActionDispatch::IntegrationTest + make_my_diffs_pretty! + + setup do + create(:oidc_provider, issuer: OIDC::Provider::GITHUB_ACTIONS_ISSUER) + end + + context "without an API key" do + context "on GET to index" do + setup do + get api_v1_rubygem_trusted_publishers_path("rails") + end + + should "deny access" do + assert_response :unauthorized + end + end + + context "on GET to show" do + setup do + get api_v1_rubygem_trusted_publisher_path("rails", 0) + end + + should "deny access" do + assert_response :unauthorized + end + end + + context "on POST to create" do + setup do + post api_v1_rubygem_trusted_publishers_path("rails"), + params: {} + end + + should "deny access" do + assert_response :unauthorized + end + end + + context "on DELETE to destory" do + setup do + delete api_v1_rubygem_trusted_publisher_path("rails", 0), + params: {} + end + + should "deny access" do + assert_response :unauthorized + end + end + end + + context "on GET to show without configure_trusted_publishers scope" do + setup do + @api_key = create(:api_key, key: "12345", scopes: %i[push_rubygem]) + @rubygem = create(:rubygem, owners: [@api_key.owner]) + + get api_v1_rubygem_trusted_publisher_path(@rubygem.slug, 2), + headers: { "HTTP_AUTHORIZATION" => "12345" } + end + + should "deny access" do + assert_response :forbidden + assert_includes @response.body, "This API key cannot perform the specified action on this gem." + end + end + + context "with an authorized API key" do + setup do + @api_key = create(:api_key, key: "12345", scopes: %i[configure_trusted_publishers]) + @rubygem = create(:rubygem, owners: [@api_key.owner], indexed: true) + end + + context "on GET to index" do + setup do + get api_v1_rubygem_trusted_publishers_path(@rubygem.slug), + headers: { "HTTP_AUTHORIZATION" => "12345" } + end + + should "return all trusted publishers" do + assert_response :success + end + + context "with a trusted publisher" do + setup do + create(:oidc_rubygem_trusted_publisher, rubygem: @rubygem) + get api_v1_rubygem_trusted_publishers_path(@rubygem.slug), + headers: { "HTTP_AUTHORIZATION" => "12345" } + end + + should "return the trusted publisher" do + assert_response :success + assert_equal 1, @response.parsed_body.size + end + end + end + + context "on GET to show" do + setup do + @trusted_publisher = create(:oidc_rubygem_trusted_publisher, rubygem: @rubygem) + get api_v1_rubygem_trusted_publisher_path(@rubygem.slug, @trusted_publisher.id), + headers: { "HTTP_AUTHORIZATION" => "12345" } + end + + should "return the trusted publisher" do + repository_name = @trusted_publisher.trusted_publisher.repository_name + + assert_response :success + assert_equal( + { "id" => @trusted_publisher.id, + "trusted_publisher_type" => "OIDC::TrustedPublisher::GitHubAction", + "trusted_publisher" => { + "name" => "GitHub Actions example/#{repository_name} @ .github/workflows/push_gem.yml", + "repository_owner" => "example", + "repository_name" => repository_name, + "repository_owner_id" => "123456", + "workflow_filename" => "push_gem.yml", + "environment" => nil + } }, @response.parsed_body + ) + end + end + + context "on POST to create" do + should "create a trusted publisher" do + stub_request(:get, "https://api.github.com/users/example") + .to_return(status: 200, body: { id: "123456" }.to_json, headers: { "Content-Type" => "application/json" }) + + post api_v1_rubygem_trusted_publishers_path(@rubygem.slug), + params: { + trusted_publisher_type: "OIDC::TrustedPublisher::GitHubAction", + trusted_publisher: { + repository_owner: "example", + repository_name: "rubygem1", + workflow_filename: "push_gem.yml" + } + }, + headers: { "HTTP_AUTHORIZATION" => "12345" } + + assert_response :created + trusted_publisher = OIDC::RubygemTrustedPublisher.find(response.parsed_body["id"]) + + assert_equal @rubygem, trusted_publisher.rubygem + assert_equal( + { "id" => response.parsed_body["id"], + "trusted_publisher_type" => "OIDC::TrustedPublisher::GitHubAction", + "trusted_publisher" => { + "name" => "GitHub Actions example/rubygem1 @ .github/workflows/push_gem.yml", + "repository_owner" => "example", + "repository_name" => "rubygem1", + "repository_owner_id" => "123456", + "workflow_filename" => "push_gem.yml", + "environment" => nil + } }, response.parsed_body + ) + end + + should "error creating trusted publisher with unknown type" do + post api_v1_rubygem_trusted_publishers_path(@rubygem.slug), + params: { + trusted_publisher_type: "Hash", + trusted_publisher: { repository_owner: "example" } + }, + headers: { "HTTP_AUTHORIZATION" => "12345" } + + assert_response :unprocessable_content + assert_equal "Unsupported trusted publisher type", response.parsed_body["error"] + end + + should "error creating trusted publisher with invalid config" do + stub_request(:get, "https://api.github.com/users/example") + .to_return(status: 200, body: { id: "123456" }.to_json, headers: { "Content-Type" => "application/json" }) + + post api_v1_rubygem_trusted_publishers_path(@rubygem.slug), + params: { + trusted_publisher_type: "OIDC::TrustedPublisher::GitHubAction", + trusted_publisher: { repository_owner: "example" } + }, + headers: { "HTTP_AUTHORIZATION" => "12345" } + + assert_response :unprocessable_content + assert_equal({ "trusted_publisher.repository_name" => ["can't be blank"], + "trusted_publisher.workflow_filename" => ["can't be blank"] }, + response.parsed_body["errors"]) + end + end + + context "on DELETE to destroy" do + should "destroy the trusted publisher" do + trusted_publisher = create(:oidc_rubygem_trusted_publisher, rubygem: @rubygem) + + delete api_v1_rubygem_trusted_publisher_path(@rubygem.slug, trusted_publisher.id), + headers: { "HTTP_AUTHORIZATION" => "12345" } + + assert_response :no_content + assert OIDC::RubygemTrustedPublisher.none?(id: trusted_publisher.id) + end + end + end +end diff --git a/test/integration/api/v1/oidc/trusted_publisher_controller_test.rb b/test/integration/api/v1/oidc/trusted_publisher_controller_test.rb new file mode 100644 index 00000000000..fa930577713 --- /dev/null +++ b/test/integration/api/v1/oidc/trusted_publisher_controller_test.rb @@ -0,0 +1,254 @@ +require "test_helper" + +class Api::V1::OIDC::TrustedPublisherControllerTest < ActionDispatch::IntegrationTest + setup do + @pkey = OpenSSL::PKey::RSA.generate(2048) + create(:oidc_provider, issuer: OIDC::Provider::GITHUB_ACTIONS_ISSUER, pkey: @pkey) + + @claims = { + "aud" => Gemcutter::HOST, + "exp" => 1_680_020_837, + "iat" => 1_680_020_537, + "iss" => "https://token.actions.githubusercontent.com", + "jti" => "79685b65-945d-450a-a3d8-a36bcf72c23d", + "nbf" => 1_680_019_937, + "ref" => "refs/heads/main", + "sha" => "04de3558bc5861874a86f8fcd67e516554101e71", + "sub" => "repo:segiddins/oidc-test:ref:refs/heads/main", + "actor" => "segiddins", + "run_id" => "4545231084", + "actor_id" => "1946610", + "base_ref" => "", + "head_ref" => "", + "ref_type" => "branch", + "workflow" => "token", + "event_name" => "push", + "repository" => "segiddins/oidc-test", + "run_number" => "4", + "run_attempt" => "1", + "workflow_ref" => "segiddins/oidc-test/.github/workflows/token.yml@refs/heads/main", + "workflow_sha" => "04de3558bc5861874a86f8fcd67e516554101e71", + "repository_id" => "620393838", + "job_workflow_ref" => "segiddins/oidc-test/.github/workflows/token.yml@refs/heads/main", + "job_workflow_sha" => "04de3558bc5861874a86f8fcd67e516554101e71", + "repository_owner" => "segiddins", + "runner_environment" => "github-hosted", + "repository_owner_id" => "1946610", + "repository_visibility" => "public" + } + + travel_to Time.zone.at(1_680_020_830) # after the JWT iat, before the exp + end + + def jwt(claims = @claims, key: @pkey) + JSON::JWT.new(claims).sign(key.to_jwk) + end + + context "POST exchange_token" do + should "return invalid request with no JWT" do + post api_v1_oidc_trusted_publisher_exchange_token_path + + assert_response :bad_request + assert_equal({ "error" => "Request is missing param 'jwt'" }, response.parsed_body) + end + + should "return invalid request with integer JWT" do + post api_v1_oidc_trusted_publisher_exchange_token_path, + params: { jwt: 1 } + + assert_response :bad_request + end + + should "return not found with no matching trusted publisher" do + post api_v1_oidc_trusted_publisher_exchange_token_path, + params: { jwt: jwt.to_s } + + assert_response :not_found + end + + should "return not found when owner has changed" do + trusted_publisher = build(:oidc_trusted_publisher_github_action, + repository_name: "oidc-test", + repository_owner_id: "123", + workflow_filename: "token.yml") + trusted_publisher.repository_owner = "segiddins" + trusted_publisher.save! + post api_v1_oidc_trusted_publisher_exchange_token_path, + params: { jwt: jwt.to_s } + + assert_response :not_found + end + + should "return not found with an unknown issuer" do + @claims["iss"] = "https://unknown.example.com" + trusted_publisher = build(:oidc_trusted_publisher_github_action, + repository_name: "oidc-test", + repository_owner_id: "1946610", + workflow_filename: "token.yml") + trusted_publisher.repository_owner = "segiddins" + trusted_publisher.save! + post api_v1_oidc_trusted_publisher_exchange_token_path, + params: { jwt: jwt.to_s } + + assert_response :not_found + end + + should "return not found with an unsupported issuer" do + @claims["iss"] = "https://unknown.example.com" + create(:oidc_provider, issuer: @claims["iss"], pkey: @pkey) + trusted_publisher = build(:oidc_trusted_publisher_github_action, + repository_name: "oidc-test", + repository_owner_id: "1946610", + workflow_filename: "token.yml") + trusted_publisher.repository_owner = "segiddins" + trusted_publisher.save! + post api_v1_oidc_trusted_publisher_exchange_token_path, + params: { jwt: jwt.to_s } + + assert_response :not_found + end + + should "return bad request with an invalid JWT" do + post api_v1_oidc_trusted_publisher_exchange_token_path, + params: { jwt: "invalid" } + + assert_response :bad_request + end + + should "return bad request with invalid JSON" do + post api_v1_oidc_trusted_publisher_exchange_token_path, + params: { jwt: "a.a.a" } + + assert_response :bad_request + end + + %w[nbf exp iat iss jti].each do |claim| + should "return bad request with missing/invalid #{claim}" do + @claims[claim] = ["a"] + post api_v1_oidc_trusted_publisher_exchange_token_path, + params: { jwt: jwt.to_s } + + assert_response :bad_request + + @claims.delete claim + post api_v1_oidc_trusted_publisher_exchange_token_path, + params: { jwt: jwt.to_s } + + assert_response :bad_request + end + end + + should "return not found when time is before nbf" do + @claims["nbf"] += 1_000_000 + trusted_publisher = build(:oidc_trusted_publisher_github_action, + repository_name: "oidc-test", + repository_owner_id: "1946610", + workflow_filename: "token.yml") + trusted_publisher.repository_owner = "segiddins" + trusted_publisher.save! + post api_v1_oidc_trusted_publisher_exchange_token_path, + params: { jwt: jwt.to_s } + + assert_response :not_found + end + + should "return not found when time is after exp" do + @claims["exp"] -= 1_000_000 + trusted_publisher = build(:oidc_trusted_publisher_github_action, + repository_name: "oidc-test", + repository_owner_id: "1946610", + workflow_filename: "token.yml") + trusted_publisher.repository_owner = "segiddins" + trusted_publisher.save! + post api_v1_oidc_trusted_publisher_exchange_token_path, + params: { jwt: jwt.to_s } + + assert_response :not_found + end + + should "return not found when signature validation fails" do + @claims["exp"] -= 1_000_000 + trusted_publisher = build(:oidc_trusted_publisher_github_action, + repository_name: "oidc-test", + repository_owner_id: "1946610", + workflow_filename: "token.yml") + trusted_publisher.repository_owner = "segiddins" + trusted_publisher.save! + post api_v1_oidc_trusted_publisher_exchange_token_path, + params: { jwt: jwt(key: OpenSSL::PKey::RSA.generate(2048)).to_s } + + assert_response :not_found + end + + should "return not found when workflow is from a different ref" do + @claims["job_workflow_ref"] = "segiddins/oidc-test/.github/workflows/token.yml@refs/heads/other" + trusted_publisher = build(:oidc_trusted_publisher_github_action, + repository_name: "oidc-test", + repository_owner_id: "1946610", + workflow_filename: "token.yml") + trusted_publisher.repository_owner = "segiddins" + trusted_publisher.save! + post api_v1_oidc_trusted_publisher_exchange_token_path, + params: { jwt: jwt.to_s } + + assert_response :not_found + end + + should "return not found when audience is wrong" do + @claims["aud"] = "other.com" + trusted_publisher = build(:oidc_trusted_publisher_github_action, + repository_name: "oidc-test", + repository_owner_id: "123", + workflow_filename: "token.yml") + trusted_publisher.repository_owner = "segiddins" + trusted_publisher.save! + post api_v1_oidc_trusted_publisher_exchange_token_path, + params: { jwt: jwt.to_s } + + assert_response :not_found + end + + should "return not found when issuer has no jwks and jwt is unsigned" do + trusted_publisher = build(:oidc_trusted_publisher_github_action, + repository_name: "oidc-test", + repository_owner_id: "1946610", + workflow_filename: "token.yml") + trusted_publisher.repository_owner = "segiddins" + trusted_publisher.save! + + OIDC::Provider.github_actions.update!(jwks: nil) + + post api_v1_oidc_trusted_publisher_exchange_token_path, + params: { jwt: JSON::JWT.new(@claims).to_s } + + assert_response :not_found + end + + should "succeed with matching trusted publisher" do + trusted_publisher = build(:oidc_trusted_publisher_github_action, + repository_name: "oidc-test", + repository_owner_id: "1946610", + workflow_filename: "token.yml") + trusted_publisher.repository_owner = "segiddins" + trusted_publisher.save! + post api_v1_oidc_trusted_publisher_exchange_token_path, + params: { jwt: jwt.to_s } + + assert_response :success + + resp = response.parsed_body + + assert_match(/^rubygems_/, resp["rubygems_api_key"]) + assert_equal({ + "rubygems_api_key" => resp["rubygems_api_key"], + "name" => "GitHub Actions segiddins/oidc-test @ .github/workflows/token.yml 2023-03-28T16:22:17Z", + "scopes" => ["push_rubygem"], + "expires_at" => 15.minutes.from_now + }, resp) + + api_key = trusted_publisher.api_keys.sole + + assert_equal api_key.owner, trusted_publisher + end + end +end diff --git a/test/integration/api/v1/owner_test.rb b/test/integration/api/v1/owner_test.rb index eb9b4706df7..936b49c6124 100644 --- a/test/integration/api/v1/owner_test.rb +++ b/test/integration/api/v1/owner_test.rb @@ -3,37 +3,44 @@ class Api::V1::OwnerTest < ActionDispatch::IntegrationTest setup do @user_api_key = "12323" - @user = create(:api_key, key: @user_api_key, add_owner: true, remove_owner: true).user + @user = create(:api_key, key: @user_api_key, scopes: %i[add_owner remove_owner]).user @other_user_api_key = "12324" - @other_user = create(:api_key, key: @other_user_api_key, add_owner: true, remove_owner: true).user - cookies[:remember_token] = @user.remember_token + @other_user = create(:api_key, key: @other_user_api_key, scopes: %i[add_owner remove_owner]).user + post session_path(session: { who: @user.handle, password: PasswordHelpers::SECURE_TEST_PASSWORD }) + + @trusted_publisher_api_key = "12325" + @trusted_publisher = create(:oidc_trusted_publisher_github_action) + create(:api_key, key: @trusted_publisher_api_key, owner: @trusted_publisher) @rubygem = create(:rubygem, number: "1.0.0") create(:ownership, user: @user, rubygem: @rubygem) end test "adding an owner" do - post api_v1_rubygem_owners_path(@rubygem), + post api_v1_rubygem_owners_path(@rubygem.slug), params: { email: @other_user.email }, headers: { "HTTP_AUTHORIZATION" => @user_api_key } + assert_response :success @ownership = @rubygem.ownerships_including_unconfirmed.find_by(user: @other_user) - get confirm_rubygem_owners_url(@rubygem, token: @ownership.token) + get confirm_rubygem_owners_url(@rubygem.slug, token: @ownership.token) + + get rubygem_path(@rubygem.slug) - get rubygem_path(@rubygem) assert page.has_selector?("a[alt='#{@user.handle}']") assert page.has_selector?("a[alt='#{@other_user.handle}']") end test "removing an owner" do create(:ownership, user: @other_user, rubygem: @rubygem) - delete api_v1_rubygem_owners_path(@rubygem), + delete api_v1_rubygem_owners_path(@rubygem.slug), params: { email: @other_user.email }, headers: { "HTTP_AUTHORIZATION" => @user_api_key } - get rubygem_path(@rubygem) + get rubygem_path(@rubygem.slug) + assert page.has_selector?("a[alt='#{@user.handle}']") refute page.has_selector?("a[alt='#{@other_user.handle}']") end @@ -41,24 +48,39 @@ class Api::V1::OwnerTest < ActionDispatch::IntegrationTest test "transferring ownership" do create(:ownership, user: @other_user, rubygem: @rubygem) - delete api_v1_rubygem_owners_path(@rubygem), + delete api_v1_rubygem_owners_path(@rubygem.slug), params: { email: @user.email }, headers: { "HTTP_AUTHORIZATION" => @user_api_key } - get rubygem_path(@rubygem) + get rubygem_path(@rubygem.slug) + refute page.has_selector?("a[alt='#{@user.handle}']") assert page.has_selector?("a[alt='#{@other_user.handle}']") end test "adding ownership without permission" do - post api_v1_rubygem_owners_path(@rubygem), + post api_v1_rubygem_owners_path(@rubygem.slug), params: { email: @other_user.email }, headers: { "HTTP_AUTHORIZATION" => @other_user_api_key } - assert_response :unauthorized - delete api_v1_rubygem_owners_path(@rubygem), + assert_response :forbidden + + delete api_v1_rubygem_owners_path(@rubygem.slug), params: { email: @other_user.email }, headers: { "HTTP_AUTHORIZATION" => @other_user_api_key } - assert_response :unauthorized + + assert_response :forbidden + + post api_v1_rubygem_owners_path(@rubygem.slug), + params: { email: @other_user.email }, + headers: { "HTTP_AUTHORIZATION" => @trusted_publisher_api_key } + + assert_response :forbidden + + delete api_v1_rubygem_owners_path(@rubygem.slug), + params: { email: @other_user.email }, + headers: { "HTTP_AUTHORIZATION" => @trusted_publisher_api_key } + + assert_response :forbidden end end diff --git a/test/integration/api/v1/rubygems_test.rb b/test/integration/api/v1/rubygems_test.rb index acac538d762..949cdaa79db 100644 --- a/test/integration/api/v1/rubygems_test.rb +++ b/test/integration/api/v1/rubygems_test.rb @@ -3,15 +3,16 @@ class Api::V1::RubygemsTest < ActionDispatch::IntegrationTest setup do @key = "12345" - create(:api_key, key: @key, index_rubygems: true, push_rubygem: true) + @user = create(:user) + create(:api_key, owner: @user, key: @key, scopes: %i[index_rubygems push_rubygem]) end test "request has remote addr present" do ip_address = "1.2.3.4" - RackAttackReset.expects(:gem_push_backoff).with(ip_address).once + RackAttackReset.expects(:gem_push_backoff).with(ip_address, @user.to_gid).once post "/api/v1/gems", - params: gem_file("test-1.0.0.gem").read, + params: gem_file("test-1.0.0.gem", &:read), headers: { REMOTE_ADDR: ip_address, HTTP_AUTHORIZATION: @key, CONTENT_TYPE: "application/octet-stream" } assert_response :success @@ -21,7 +22,7 @@ class Api::V1::RubygemsTest < ActionDispatch::IntegrationTest RackAttackReset.expects(:gem_push_backoff).never post "/api/v1/gems", - params: gem_file("test-1.0.0.gem").read, + params: gem_file("test-1.0.0.gem", &:read), headers: { REMOTE_ADDR: "", HTTP_AUTHORIZATION: @key, CONTENT_TYPE: "application/octet-stream" } assert_response :success diff --git a/test/integration/api/v1/search_test.rb b/test/integration/api/v1/search_test.rb index 22f4134c0c6..8b8e39d4488 100644 --- a/test/integration/api/v1/search_test.rb +++ b/test/integration/api/v1/search_test.rb @@ -3,6 +3,7 @@ class Api::V1::Search < ActionDispatch::IntegrationTest test "request with non-string query shows bad request" do get "/api/v1/search.json?query[]=" + assert_response :bad_request end end diff --git a/test/integration/api/v2/version_information_test.rb b/test/integration/api/v2/version_information_test.rb index 482904b1aae..e24ca4f672a 100644 --- a/test/integration/api/v2/version_information_test.rb +++ b/test/integration/api/v2/version_information_test.rb @@ -7,57 +7,67 @@ class VersionInformationTest < ActionDispatch::IntegrationTest end def request_endpoint(rubygem, version, format = "json") - get api_v2_rubygem_version_path(rubygem.name, version, format: format) + get api_v2_rubygem_version_path(rubygem.slug, version, format: format) end test "return gem version" do request_endpoint(@rubygem, "2.0.0") + assert_response :success json_response = JSON.load(@response.body) + assert_kind_of Hash, json_response assert_equal "2.0.0", json_response["number"] end test "return success for yaml extension api call" do request_endpoint(@rubygem, "2.0.0", "yaml") + assert_response :success end test "has required fields" do request_endpoint(@rubygem, "2.0.0") json_response = JSON.load(@response.body) - json_response["sha"] - json_response["platform"] - json_response["ruby_version"] + + assert json_response.key?("sha") + assert json_response.key?("platform") + assert json_response.key?("ruby_version") end test "version does not exist" do request_endpoint(@rubygem, "1.2.3") + assert_response :not_found assert_equal "This version could not be found.", @response.body end test "gem does not exist" do request_endpoint(Rubygem.new(name: "nonexistent_gem"), "2.0.0") + assert_response :not_found assert_equal "This gem could not be found", @response.body end test "second get returns not modified" do request_endpoint(@rubygem, "2.0.0") + assert_response :success - get api_v2_rubygem_version_path(@rubygem.name, "2.0.0", format: "json"), headers: { + get api_v2_rubygem_version_path(@rubygem.slug, "2.0.0", format: "json"), headers: { "HTTP_IF_MODIFIED_SINCE" => @response.headers["Last-Modified"], "HTTP_IF_NONE_MATCH" => @response.etag } + assert_response :not_modified end test "rubygem has .(dot) in name" do @rubygem.update_attribute(:name, "ruby.ruby.ruby") request_endpoint(@rubygem, "2.0.0") + assert_response :success json_response = JSON.load(@response.body) + assert_equal "2.0.0", json_response["number"] end end diff --git a/test/integration/api_keys_test.rb b/test/integration/api_keys_test.rb deleted file mode 100644 index 02fba54f4a6..00000000000 --- a/test/integration/api_keys_test.rb +++ /dev/null @@ -1,62 +0,0 @@ -require "test_helper" - -class ApiKeysTest < SystemTest - setup do - @user = create(:user) - - visit sign_in_path - fill_in "Email or Username", with: @user.email - fill_in "Password", with: @user.password - click_button "Sign in" - end - - test "creating new api key" do - visit_profile_api_keys_path - - fill_in "api_key[name]", with: "test" - check "api_key[index_rubygems]" - click_button "Create" - - assert page.has_content? "Note that we won't be able to show the key to you again. New API key:" - assert @user.api_keys.last.can_index_rubygems? - end - - test "update api key" do - api_key = create(:api_key, user: @user) - - visit_profile_api_keys_path - click_button "Edit" - - assert page.has_content? "Edit API key" - check "api_key[add_owner]" - click_button "Update" - - assert api_key.reload.can_add_owner? - end - - test "deleting api key" do - create(:api_key, user: @user) - - visit_profile_api_keys_path - click_button "Delete" - - assert page.has_content? "New API key" - end - - test "deleting all api key" do - create(:api_key, user: @user) - - visit_profile_api_keys_path - click_button "Reset" - - assert page.has_content? "New API key" - end - - def visit_profile_api_keys_path - visit profile_api_keys_path - return unless page.has_css? "#verify_password_password" - - fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD - click_button "Confirm" - end -end diff --git a/test/integration/avatars_test.rb b/test/integration/avatars_test.rb new file mode 100644 index 00000000000..ae49b415d95 --- /dev/null +++ b/test/integration/avatars_test.rb @@ -0,0 +1,77 @@ +require "test_helper" + +class AvatarsTest < ActionDispatch::IntegrationTest + test "returns 404 when no user is found" do + get avatar_user_path("user", size: 64) + + assert_response :not_found + end + + test "redirects to default avatar when gravatar returns 404" do + stub_request(:get, Addressable::Template.new("https://secure.gravatar.com/avatar/{hash}.png?d=404&r=PG&s={size}")) + .to_return(status: 404) + + user = create(:user) + get avatar_user_path(user.id, size: 64) + + assert_response :found + assert_equal "http://localhost/images/avatar.svg", response.headers["Location"] + end + + test "serves gravatar response on 200" do + stub_request(:get, Addressable::Template.new("https://secure.gravatar.com/avatar/{hash}.png?d=404&r=PG&s=64")) + .to_return(status: 200, body: "image", headers: { + "Content-Type" => "image/jpeg", + "Last-Modified" => "Wed, 21 Oct 2015 07:28:00 GMT", + "Link" => "foo" + }) + + user = create(:user) + get avatar_user_path(user.id, size: 64) + + assert_response :success + assert_equal "image/jpeg", response.headers["Content-Type"] + assert_equal "image", response.body + assert_nil response.headers["Link"] + end + + test "serves default avatar with theme when user has no gravatar" do + user = create(:user) + get avatar_user_path(user.id, size: 64, theme: "dark") + + assert_response :found + assert_equal "http://localhost/images/avatar_inverted.svg", response.headers["Location"] + end + + test "falls back to default avatar when gravatar returns 500" do + stub_request(:get, Addressable::Template.new("https://secure.gravatar.com/avatar/{hash}.png?d=404&r=PG&s=64")) + .to_return(status: 500) + + user = create(:user) + get avatar_user_path(user.id, size: 64) + + assert_response :found + assert_equal "http://localhost/images/avatar.svg", response.headers["Location"] + end + + test "returns 400 when size is invalid" do + user = create(:user) + get avatar_user_path(user.id, size: 0) + + assert_response :bad_request + assert_equal "Invalid size", response.body + + get avatar_user_path(user.id, size: 2049) + + assert_response :bad_request + assert_equal "Invalid size", response.body + end + + test "returns 400 when theme is invalid" do + user = create(:user) + get avatar_user_path(user.id, size: 64, theme: "unknown") + + assert_response :bad_request + assert_equal "Invalid theme", response.body + end +end diff --git a/test/integration/avo/admin_github_users_test.rb b/test/integration/avo/admin_github_users_test.rb new file mode 100644 index 00000000000..90d5205a021 --- /dev/null +++ b/test/integration/avo/admin_github_users_test.rb @@ -0,0 +1,22 @@ +require "test_helper" + +class Avo::AdminGitHubUsersTest < ActionDispatch::IntegrationTest + include AdminHelpers + + test "getting admin github users as admin" do + user = create(:admin_github_user, :is_admin) + admin_sign_in_as user + + get avo.resources_admin_github_users_path + + assert_response :success + assert page.has_content? user.login + assert page.has_content? user.github_id + + get avo.resources_admin_github_user_path(user) + + assert_response :success + assert page.has_content? user.name + assert page.has_content? user.github_id + end +end diff --git a/test/integration/avo/audits_controller_test.rb b/test/integration/avo/audits_controller_test.rb new file mode 100644 index 00000000000..5618b4d7ddb --- /dev/null +++ b/test/integration/avo/audits_controller_test.rb @@ -0,0 +1,79 @@ +require "test_helper" + +class Avo::AuditsControllerTest < ActionDispatch::IntegrationTest + include AdminHelpers + + test "getting audits as admin" do + admin = create(:admin_github_user, :is_admin) + admin_sign_in_as admin + + get avo.resources_audits_path + + assert_response :success + + user = create(:user) + audit = create(:audit, auditable: user, models: [user], records: { + user.to_global_id.uri => { + "changes" => { + "encrypted_password" => %w[abc def] + }, + "unchanged" => {} + } + }) + + deleted_user = create(:user).tap(&:destroy!) + deletion_audit = create(:audit, auditable: user, models: [deleted_user], records: { + deleted_user.to_global_id.uri => { + "changes" => { + "id" => [deleted_user.id, nil], + "encrypted_password" => ["abc", nil] + }, + "unchanged" => {} + } + }) + + insertion_audit = create(:audit, auditable: user, models: [deleted_user], records: { + user.to_global_id.uri => { + "changes" => { + "id" => [nil, user.id], + "encrypted_password" => [nil, "abc"] + }, + "unchanged" => {} + } + }) + + empty_audit = create(:audit, auditable: user) + + get avo.resources_audits_path + + assert_response :success + page.assert_text audit.action + page.assert_text deletion_audit.action + page.assert_text insertion_audit.action + page.assert_text empty_audit.action + + get avo.resources_audit_path(audit) + + assert_response :success + page.assert_text audit.action + page.assert_text audit.comment + + get avo.resources_audit_path(deletion_audit) + + assert_response :success + page.assert_text deletion_audit.action + page.assert_text deletion_audit.comment + + get avo.resources_audit_path(insertion_audit) + + assert_response :success + page.assert_text insertion_audit.action + page.assert_text insertion_audit.comment + + get avo.resources_audit_path(empty_audit) + + assert_response :success + page.assert_text empty_audit.action + page.assert_text empty_audit.comment + end +end diff --git a/test/integration/avo/deletion_controller_test.rb b/test/integration/avo/deletion_controller_test.rb new file mode 100644 index 00000000000..5f5b63220e1 --- /dev/null +++ b/test/integration/avo/deletion_controller_test.rb @@ -0,0 +1,25 @@ +require "test_helper" + +class Avo::DeletionControllerTest < ActionDispatch::IntegrationTest + include AdminHelpers + + test "getting deletion as admin" do + admin_sign_in_as create(:admin_github_user, :is_admin) + + get avo.resources_deletions_path + + assert_response :success + + deletion = create(:deletion) + + get avo.resources_deletions_path + + assert_response :success + assert page.has_content? deletion.id + + get avo.resources_deletion_path(deletion) + + assert_response :success + assert page.has_content? deletion.id + end +end diff --git a/test/integration/avo/gem_downloads_controller_test.rb b/test/integration/avo/gem_downloads_controller_test.rb new file mode 100644 index 00000000000..38bfb533ec3 --- /dev/null +++ b/test/integration/avo/gem_downloads_controller_test.rb @@ -0,0 +1,27 @@ +require "test_helper" + +class Avo::GemDownloadsControllerTest < ActionDispatch::IntegrationTest + include AdminHelpers + + test "getting gem_downloads as admin" do + admin_sign_in_as create(:admin_github_user, :is_admin) + + get avo.resources_gem_downloads_path + + assert_response :success + + version = create(:version) + gem_download = version.gem_download + GemDownload.bulk_update([[version.full_name, 100]]) + + get avo.resources_gem_downloads_path + + assert_response :success + page.assert_text gem_download.count.to_s + + get avo.resources_gem_download_path(gem_download) + + assert_response :success + page.assert_text gem_download.count.to_s + end +end diff --git a/test/integration/avo/gem_name_reservations_controller_test.rb b/test/integration/avo/gem_name_reservations_controller_test.rb new file mode 100644 index 00000000000..58abc37dacb --- /dev/null +++ b/test/integration/avo/gem_name_reservations_controller_test.rb @@ -0,0 +1,19 @@ +require "test_helper" + +class Avo::GemNameReservationsControllerTest < ActionDispatch::IntegrationTest + include AdminHelpers + + test "getting gem_downloads as admin" do + admin_sign_in_as create(:admin_github_user, :is_admin) + create(:gem_name_reservation, name: "hello") + + get avo.resources_gem_name_reservations_path + + assert_response :success + + # test resource search_query scope + get avo.avo_api_search_path(q: "hello") + + assert_response :success + end +end diff --git a/test/integration/avo/link_verifications_controller_test.rb b/test/integration/avo/link_verifications_controller_test.rb new file mode 100644 index 00000000000..a1947658bf9 --- /dev/null +++ b/test/integration/avo/link_verifications_controller_test.rb @@ -0,0 +1,25 @@ +require "test_helper" + +class Avo::LinkVerificationsControllerTest < ActionDispatch::IntegrationTest + include AdminHelpers + + test "getting link_verifications as admin" do + admin_sign_in_as create(:admin_github_user, :is_admin) + + get avo.resources_link_verifications_path + + assert_response :success + + link_verification = create(:link_verification) + + get avo.resources_link_verifications_path + + assert_response :success + page.assert_text link_verification.uri + + get avo.resources_link_verification_path(link_verification) + + assert_response :success + page.assert_text link_verification.uri + end +end diff --git a/test/integration/avo/linksets_test.rb b/test/integration/avo/linksets_test.rb new file mode 100644 index 00000000000..14dc313c1ef --- /dev/null +++ b/test/integration/avo/linksets_test.rb @@ -0,0 +1,25 @@ +require "test_helper" + +class Avo::LinksetTest < ActionDispatch::IntegrationTest + include AdminHelpers + + test "getting linksets as admin" do + admin_sign_in_as create(:admin_github_user, :is_admin) + + get avo.resources_linksets_path + + assert_response :success + + linkset = create(:linkset) + + get avo.resources_linksets_path + + assert_response :success + assert page.has_content? linkset.rubygem.name + + get avo.resources_linkset_path(linkset) + + assert_response :success + assert page.has_content? linkset.rubygem.name + end +end diff --git a/test/integration/avo/log_tickets_controller_test.rb b/test/integration/avo/log_tickets_controller_test.rb new file mode 100644 index 00000000000..99ea33a851e --- /dev/null +++ b/test/integration/avo/log_tickets_controller_test.rb @@ -0,0 +1,25 @@ +require "test_helper" + +class Avo::LogTicketsControllerTest < ActionDispatch::IntegrationTest + include AdminHelpers + + test "getting log_tickets as admin" do + admin_sign_in_as create(:admin_github_user, :is_admin) + + get avo.resources_log_tickets_path + + assert_response :success + + log_ticket = create(:log_ticket) + + get avo.resources_log_tickets_path + + assert_response :success + page.assert_text log_ticket.key + + get avo.resources_log_ticket_path(log_ticket) + + assert_response :success + page.assert_text log_ticket.key + end +end diff --git a/test/integration/avo/maintenance_task_runs_controller_test.rb b/test/integration/avo/maintenance_task_runs_controller_test.rb new file mode 100644 index 00000000000..c19cae421ba --- /dev/null +++ b/test/integration/avo/maintenance_task_runs_controller_test.rb @@ -0,0 +1,24 @@ +require "test_helper" + +class Avo::MaintenanceTaskRunsControllerTest < ActionDispatch::IntegrationTest + include AdminHelpers + + test "getting runs as admin" do + admin = create(:admin_github_user, :is_admin) + admin_sign_in_as admin + + get avo.resources_maintenance_tasks_runs_path + + assert_response :success + + MaintenanceTasks::Runner.run(name: "Maintenance::UserTotpSeedEmptyToNilTask") + + get avo.resources_maintenance_tasks_runs_path + + assert_response :success + + get avo.resources_maintenance_tasks_run_path(MaintenanceTasks::Run.sole) + + assert_response :success + end +end diff --git a/test/integration/avo/oidc_api_key_roles_controller_test.rb b/test/integration/avo/oidc_api_key_roles_controller_test.rb new file mode 100644 index 00000000000..91f3787307f --- /dev/null +++ b/test/integration/avo/oidc_api_key_roles_controller_test.rb @@ -0,0 +1,25 @@ +require "test_helper" + +class Avo::OIDCApiKeyRolesControllerTest < ActionDispatch::IntegrationTest + include AdminHelpers + + test "getting api key roles as admin" do + admin_sign_in_as create(:admin_github_user, :is_admin) + + get avo.resources_oidc_api_key_roles_path + + assert_response :success + + oidc_api_key_role = create(:oidc_api_key_role) + + get avo.resources_oidc_api_key_roles_path + + assert_response :success + page.assert_text oidc_api_key_role.name + + get avo.resources_oidc_api_key_role_path(oidc_api_key_role) + + assert_response :success + page.assert_text oidc_api_key_role.name + end +end diff --git a/test/integration/avo/oidc_id_tokens_controller_test.rb b/test/integration/avo/oidc_id_tokens_controller_test.rb new file mode 100644 index 00000000000..9d8f06b44a5 --- /dev/null +++ b/test/integration/avo/oidc_id_tokens_controller_test.rb @@ -0,0 +1,25 @@ +require "test_helper" + +class Avo::OIDCIdTokensControllerTest < ActionDispatch::IntegrationTest + include AdminHelpers + + test "getting id tokens as admin" do + admin_sign_in_as create(:admin_github_user, :is_admin) + + get avo.resources_oidc_id_tokens_path + + assert_response :success + + oidc_id_token = create(:oidc_id_token) + + get avo.resources_oidc_id_tokens_path + + assert_response :success + page.assert_text oidc_id_token.api_key.name + + get avo.resources_oidc_id_token_path(oidc_id_token) + + assert_response :success + page.assert_text oidc_id_token.jwt["claims"].values.first + end +end diff --git a/test/integration/avo/oidc_pending_trusted_publishers_controller_test.rb b/test/integration/avo/oidc_pending_trusted_publishers_controller_test.rb new file mode 100644 index 00000000000..46193b3da45 --- /dev/null +++ b/test/integration/avo/oidc_pending_trusted_publishers_controller_test.rb @@ -0,0 +1,27 @@ +require "test_helper" + +class Avo::OIDCPendingTrustedPublishersControllerTest < ActionDispatch::IntegrationTest + include AdminHelpers + + test "getting pending trusted publishers as admin" do + admin_sign_in_as create(:admin_github_user, :is_admin) + + get avo.resources_oidc_pending_trusted_publishers_path + + assert_response :success + + oidc_pending_trusted_publisher = create(:oidc_pending_trusted_publisher) + + get avo.resources_oidc_pending_trusted_publishers_path + + assert_response :success + page.assert_text oidc_pending_trusted_publisher.rubygem_name + page.assert_text oidc_pending_trusted_publisher.trusted_publisher.name + + get avo.resources_oidc_pending_trusted_publisher_path(oidc_pending_trusted_publisher) + + assert_response :success + page.assert_text oidc_pending_trusted_publisher.rubygem_name + page.assert_text oidc_pending_trusted_publisher.trusted_publisher.name + end +end diff --git a/test/integration/avo/oidc_providers_controller_test.rb b/test/integration/avo/oidc_providers_controller_test.rb new file mode 100644 index 00000000000..52a0b7cd585 --- /dev/null +++ b/test/integration/avo/oidc_providers_controller_test.rb @@ -0,0 +1,25 @@ +require "test_helper" + +class Avo::OIDCProvidersControllerTest < ActionDispatch::IntegrationTest + include AdminHelpers + + test "getting providers as admin" do + admin_sign_in_as create(:admin_github_user, :is_admin) + + get avo.resources_oidc_providers_path + + assert_response :success + + oidc_provider = create(:oidc_provider) + + get avo.resources_oidc_providers_path + + assert_response :success + page.assert_text oidc_provider.issuer + + get avo.resources_oidc_provider_path(oidc_provider) + + assert_response :success + page.assert_text oidc_provider.issuer + end +end diff --git a/test/integration/avo/oidc_rubygem_trusted_publishers_controller_test.rb b/test/integration/avo/oidc_rubygem_trusted_publishers_controller_test.rb new file mode 100644 index 00000000000..7a9a643cd03 --- /dev/null +++ b/test/integration/avo/oidc_rubygem_trusted_publishers_controller_test.rb @@ -0,0 +1,27 @@ +require "test_helper" + +class Avo::OIDCRubygemTrustedPublishersControllerTest < ActionDispatch::IntegrationTest + include AdminHelpers + + test "getting rubygem trusted publishers as admin" do + admin_sign_in_as create(:admin_github_user, :is_admin) + + get avo.resources_oidc_rubygem_trusted_publishers_path + + assert_response :success + + oidc_rubygem_trusted_publisher = create(:oidc_rubygem_trusted_publisher) + + get avo.resources_oidc_rubygem_trusted_publishers_path + + assert_response :success + page.assert_text oidc_rubygem_trusted_publisher.rubygem.name + page.assert_text oidc_rubygem_trusted_publisher.trusted_publisher.name + + get avo.resources_oidc_rubygem_trusted_publisher_path(oidc_rubygem_trusted_publisher) + + assert_response :success + page.assert_text oidc_rubygem_trusted_publisher.rubygem.name + page.assert_text oidc_rubygem_trusted_publisher.trusted_publisher.name + end +end diff --git a/test/integration/avo/oidc_trusted_publisher_github_actions_controller_test.rb b/test/integration/avo/oidc_trusted_publisher_github_actions_controller_test.rb new file mode 100644 index 00000000000..981aa0c9782 --- /dev/null +++ b/test/integration/avo/oidc_trusted_publisher_github_actions_controller_test.rb @@ -0,0 +1,25 @@ +require "test_helper" + +class Avo::OIDCTrustedPublisherGitHubActionsControllerTest < ActionDispatch::IntegrationTest + include AdminHelpers + + test "getting github actions trusted publishers as admin" do + admin_sign_in_as create(:admin_github_user, :is_admin) + + get avo.resources_oidc_trusted_publisher_github_actions_path + + assert_response :success + + oidc_trusted_publisher_github_action = create(:oidc_trusted_publisher_github_action) + + get avo.resources_oidc_trusted_publisher_github_actions_path + + assert_response :success + page.assert_text oidc_trusted_publisher_github_action.repository_owner + + get avo.resources_oidc_trusted_publisher_github_action_path(oidc_trusted_publisher_github_action) + + assert_response :success + page.assert_text oidc_trusted_publisher_github_action.repository_owner + end +end diff --git a/test/integration/avo/rubygems_test.rb b/test/integration/avo/rubygems_test.rb new file mode 100644 index 00000000000..c9d8d1b40ab --- /dev/null +++ b/test/integration/avo/rubygems_test.rb @@ -0,0 +1,25 @@ +require "test_helper" + +class Avo::RubygemsTest < ActionDispatch::IntegrationTest + include AdminHelpers + + test "getting rubygems as admin" do + admin_sign_in_as create(:admin_github_user, :is_admin) + + get avo.resources_rubygems_path + + assert_response :success + + rubygem = create(:rubygem) + + get avo.resources_rubygems_path + + assert_response :success + assert page.has_content? rubygem.name + + get avo.resources_rubygem_path(rubygem) + + assert_response :success + assert page.has_content? rubygem.name + end +end diff --git a/test/integration/avo/users_test.rb b/test/integration/avo/users_test.rb new file mode 100644 index 00000000000..050b01e45a3 --- /dev/null +++ b/test/integration/avo/users_test.rb @@ -0,0 +1,25 @@ +require "test_helper" + +class Avo::UsersTest < ActionDispatch::IntegrationTest + include AdminHelpers + + test "getting users as admin" do + admin_sign_in_as create(:admin_github_user, :is_admin) + + get avo.resources_users_path + + assert_response :success + + user = create(:user) + + get avo.resources_users_path + + assert_response :success + assert page.has_content? user.name + + get avo.resources_user_path(user) + + assert_response :success + assert page.has_content? user.name + end +end diff --git a/test/integration/avo/web_hooks_controller_test.rb b/test/integration/avo/web_hooks_controller_test.rb new file mode 100644 index 00000000000..6d18c9e4311 --- /dev/null +++ b/test/integration/avo/web_hooks_controller_test.rb @@ -0,0 +1,25 @@ +require "test_helper" + +class Avo::WebHooksControllerTest < ActionDispatch::IntegrationTest + include AdminHelpers + + test "getting web_hooks as admin" do + admin_sign_in_as create(:admin_github_user, :is_admin) + + get avo.resources_web_hooks_path + + assert_response :success + + web_hook = create(:web_hook) + + get avo.resources_web_hooks_path + + assert_response :success + page.assert_text web_hook.url + + get avo.resources_web_hook_path(web_hook) + + assert_response :success + page.assert_text web_hook.url + end +end diff --git a/test/integration/avo/webauthn_credentials_controller_test.rb b/test/integration/avo/webauthn_credentials_controller_test.rb new file mode 100644 index 00000000000..f79ed681ebd --- /dev/null +++ b/test/integration/avo/webauthn_credentials_controller_test.rb @@ -0,0 +1,16 @@ +require "test_helper" + +class Avo::WebauthnCredentialsControllerTest < ActionDispatch::IntegrationTest + include AdminHelpers + + test "getting webauthn credentials as admin" do + admin_sign_in_as create(:admin_github_user, :is_admin) + + webauthn_credential = create(:webauthn_credential) + + get avo.resources_webauthn_credential_path(webauthn_credential) + + assert_response :success + page.assert_text webauthn_credential.external_id + end +end diff --git a/test/integration/avo/webauthn_verifications_controller_test.rb b/test/integration/avo/webauthn_verifications_controller_test.rb new file mode 100644 index 00000000000..f0111e28288 --- /dev/null +++ b/test/integration/avo/webauthn_verifications_controller_test.rb @@ -0,0 +1,16 @@ +require "test_helper" + +class Avo::WebauthnVerificationsControllerTest < ActionDispatch::IntegrationTest + include AdminHelpers + + test "getting webauthn verifications as admin" do + admin_sign_in_as create(:admin_github_user, :is_admin) + + webauthn_verification = create(:webauthn_verification) + + get avo.resources_webauthn_verification_path(webauthn_verification) + + assert_response :success + page.assert_text webauthn_verification.path_token + end +end diff --git a/test/integration/dashboard_test.rb b/test/integration/dashboard_test.rb index 730c44ef973..37f96e5eb3a 100644 --- a/test/integration/dashboard_test.rb +++ b/test/integration/dashboard_test.rb @@ -3,22 +3,24 @@ class DashboardTest < ActionDispatch::IntegrationTest setup do @user = create(:user, remember_token_expires_at: Gemcutter::REMEMBER_FOR.from_now) - cookies[:remember_token] = @user.remember_token + post session_path(session: { who: @user.handle, password: PasswordHelpers::SECURE_TEST_PASSWORD }) create(:rubygem, name: "arrakis", number: "1.0.0") end test "request with array of api keys does not pass autorization" do - cookies[:remember_token] = nil - create(:api_key, user: @user, key: "1234", show_dashboard: true) + delete sign_out_path + create(:api_key, owner: @user, key: "1234", scopes: %i[show_dashboard]) rubygem = create(:rubygem, name: "sandworm", number: "1.0.0") create(:subscription, rubygem: rubygem, user: @user) get "/dashboard.atom?api_key=1234", as: :json + assert page.has_content? "sandworm" get "/dashboard.atom?api_key[]=1234&api_key[]=key1", as: :json + refute page.has_content? "sandworm" end @@ -53,6 +55,7 @@ class DashboardTest < ActionDispatch::IntegrationTest create(:subscription, rubygem: rubygem, user: @user) get dashboard_path(format: :atom) + assert_response :success assert_equal "application/atom+xml", response.media_type assert page.has_content? "sandworm" diff --git a/test/integration/email_confirmation_test.rb b/test/integration/email_confirmation_test.rb index 123efbb4c50..d55eaa3c727 100644 --- a/test/integration/email_confirmation_test.rb +++ b/test/integration/email_confirmation_test.rb @@ -2,6 +2,8 @@ require "helpers/email_helpers" class EmailConfirmationTest < SystemTest + include ActiveJob::TestHelper + setup do @user = create(:user) end @@ -24,33 +26,122 @@ def request_confirmation_mail(email) request_confirmation_mail @user.email link = last_email_link + assert_not_nil link visit link - assert page.has_content? "Sign out" + assert page.has_content? "Sign in" assert page.has_selector? "#flash_notice", text: "Your email address has been verified" end - test "re-using confirmation link does not sign in user" do + test "re-using confirmation link, asks user to double check the link" do request_confirmation_mail @user.email link = last_email_link visit link - click_link "Sign out" + + assert page.has_content? "Sign in" + assert page.has_selector? "#flash_notice", text: "Your email address has been verified" visit link + assert page.has_content? "Sign in" assert page.has_selector? "#flash_alert", text: "Please double check the URL or try submitting it again." end test "requesting multiple confirmation email" do + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + request_confirmation_mail @user.email + end + request_confirmation_mail @user.email + + performed = 0 + perform_enqueued_jobs only: ->(job) { job.is_a?(ActionMailer::MailDeliveryJob) && (performed += 1) == 1 } + link = confirmation_link + visit link + + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob + + assert_no_enqueued_jobs + end + + test "requesting confirmation mail with mfa enabled" do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + request_confirmation_mail @user.email + + link = last_email_link + + assert_not_nil link + visit link + + fill_in "otp", with: ROTP::TOTP.new(@user.totp_seed).now + click_button "Authenticate" + + assert page.has_content? "Sign in" + assert page.has_selector? "#flash_notice", text: "Your email address has been verified" + end + + test "requesting confirmation mail with webauthn enabled" do + create_webauthn_credential + + request_confirmation_mail @user.email + + link = last_email_link + + assert_not_nil link + visit link + + assert page.has_content? "Multi-factor authentication" + assert page.has_content? "Security Device" + + click_on "Authenticate with security device" + + assert page.has_content? "Sign in" + skip("There's a glitch where the webauthn javascript(?) triggers the next page to render twice, clearing flash.") + + assert page.has_selector? "#flash_notice", text: "Your email address has been verified" + end + + test "requesting confirmation mail with webauthn enabled using recovery codes" do + create_webauthn_credential + request_confirmation_mail @user.email + + link = last_email_link + + assert_not_nil link + visit link + + assert page.has_content? "Multi-factor authentication" + assert page.has_content? "Security Device" + + fill_in "otp", with: @mfa_recovery_codes.first + click_button "Authenticate" + + assert page.has_content? "Sign in" + assert page.has_selector? "#flash_notice", text: "Your email address has been verified" + end + + test "requesting confirmation mail with mfa enabled, but mfa session is expired" do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_gem_signin) request_confirmation_mail @user.email - link = confirmation_link_from(Delayed::Job.first) + link = last_email_link + + assert_not_nil link visit link - Delayed::Worker.new.work_off - assert_empty Delayed::Job.all + fill_in "otp", with: ROTP::TOTP.new(@user.totp_seed).now + travel 16.minutes do + click_button "Authenticate" + + assert page.has_content? "Your login page session has expired." + end + end + + teardown do + @authenticator&.remove! + Capybara.reset_sessions! + Capybara.use_default_driver end end diff --git a/test/integration/encoding_test.rb b/test/integration/encoding_test.rb index 6ab519af37a..80b25bf9e5b 100644 --- a/test/integration/encoding_test.rb +++ b/test/integration/encoding_test.rb @@ -2,12 +2,14 @@ class EncodingTest < ActionDispatch::IntegrationTest test "invalid utf-8 characters should be sanitized" do - get "/api/v1/dependencies?gems=vagrant,vagrant-login,vagrant-share,vagrant%ADvbguest" + get "/api/v1/search.json?query=vagrant%ADvbguest" + assert_response :success end test "gzip not supported" do get "/" + assert_response :success assert_nil @response.headers["Content-Encoding"] end diff --git a/test/integration/gems_test.rb b/test/integration/gems_test.rb index 49885b9a4e8..4a16312365f 100644 --- a/test/integration/gems_test.rb +++ b/test/integration/gems_test.rb @@ -7,12 +7,14 @@ class GemsTest < ActionDispatch::IntegrationTest end test "gem page with a non valid HTTP_ACCEPT header" do - get rubygem_path(@rubygem), headers: { "HTTP_ACCEPT" => "application/mercurial-0.1" } + get rubygem_path(@rubygem.slug), headers: { "HTTP_ACCEPT" => "application/mercurial-0.1" } + assert page.has_content? "1.0.0" end test "gems page with atom format" do get rubygems_path(format: :atom) + assert_response :success assert_equal "application/atom+xml", response.media_type assert page.has_content? "sandworm" @@ -20,27 +22,48 @@ class GemsTest < ActionDispatch::IntegrationTest test "versions with atom format" do create(:version, rubygem: @rubygem) - get rubygem_versions_path(@rubygem, format: :atom) + get rubygem_versions_path(@rubygem.slug, format: :atom) + assert_equal "application/atom+xml", response.media_type assert page.has_content? "sandworm" end - test "canonical url for gem points to most recent version" do + test "canonical/alternate urls for gem points to most recent version" do + base_url = "http://localhost/gems/sandworm/versions/1.1.1".freeze create(:version, rubygem: @rubygem, number: "1.1.1") - get rubygem_path(@rubygem) + get rubygem_path(@rubygem.slug) + css = %(link[rel="canonical"][href="#{base_url}"]) + + assert page.has_css?(css, visible: false) + css = %(link[rel="alternate"][hreflang]) + alternates = page.all(:css, css, visible: false) + # I18n.available_locales.length + 1 (x-default) + assert_equal (I18n.available_locales.length + 1), alternates.length + exp = I18n.available_locales.map { "#{base_url}?locale=#{_1}" } << base_url + act = alternates.pluck(:href) + + assert_same_elements exp, act + end + + test "canonical locale urls for gem points to most recent version without locale" do + create(:version, rubygem: @rubygem, number: "1.1.1") + get rubygem_path(@rubygem.slug, locale: "en") css = %(link[rel="canonical"][href="http://localhost/gems/sandworm/versions/1.1.1"]) + assert page.has_css?(css, visible: false) end test "canonical url for an old version" do create(:version, rubygem: @rubygem, number: "1.1.1") - get rubygem_version_path(@rubygem, "1.0.0") + get rubygem_version_path(@rubygem.slug, "1.0.0") css = %(link[rel="canonical"][href="http://localhost/gems/sandworm/versions/1.0.0"]) + assert page.has_css?(css, visible: false) end test "letter param is not string" do get rubygems_path(letter: ["S"]) + assert_response :success end end @@ -49,19 +72,22 @@ class GemsSystemTest < SystemTest setup do @user = create(:user) @rubygem = create(:rubygem, name: "sandworm", number: "1.0.0") - create(:version, rubygem: @rubygem, number: "1.1.1") + @version = create(:version, rubygem: @rubygem, number: "1.1.1") end test "version navigation" do - visit rubygem_version_path(@rubygem, "1.0.0") + visit rubygem_version_path(@rubygem.slug, "1.0.0") click_link "Next version →" - assert_equal page.current_path, rubygem_version_path(@rubygem, "1.1.1") + + assert_equal page.current_path, rubygem_version_path(@rubygem.slug, "1.1.1") click_link "← Previous version" - assert_equal page.current_path, rubygem_version_path(@rubygem, "1.0.0") + + assert_equal page.current_path, rubygem_version_path(@rubygem.slug, "1.0.0") end test "subscribe to a gem" do - visit rubygem_path(@rubygem, as: @user.id) + visit rubygem_path(@rubygem.slug, as: @user.id) + assert page.has_css?("a#subscribe") click_link "Subscribe" @@ -73,7 +99,8 @@ class GemsSystemTest < SystemTest test "unsubscribe to a gem" do create(:subscription, rubygem: @rubygem, user: @user) - visit rubygem_path(@rubygem, as: @user.id) + visit rubygem_path(@rubygem.slug, as: @user.id) + assert page.has_css?("a#unsubscribe") click_link "Unsubscribe" @@ -82,40 +109,50 @@ class GemsSystemTest < SystemTest assert_empty @user.subscribed_gems end + test "shows enable MFA instructions when logged in as owner with MFA disabled" do + create(:ownership, rubygem: @rubygem, user: @user) + + visit rubygem_path(@rubygem.slug, as: @user.id) + + assert page.has_selector?(".gem__users__mfa-disabled .gem__users a") + assert page.has_content? "Please consider enabling multi-factor" + end + test "shows owners without mfa when logged in as owner" do - @user.enable_mfa!("some-seed", "ui_and_api") - user_without_mfa = create(:user, mfa_level: "disabled") + @user.enable_totp!("some-seed", "ui_and_api") + user_without_mfa = create(:user) create(:ownership, rubygem: @rubygem, user: @user) create(:ownership, rubygem: @rubygem, user: user_without_mfa) - visit rubygem_path(@rubygem, as: @user.id) + visit rubygem_path(@rubygem.slug, as: @user.id) assert page.has_selector?(".gem__users__mfa-disabled .gem__users a") assert page.has_selector?(".gem__users__mfa-text.mfa-warn") end test "show mfa enabled when logged in as owner but everyone has mfa enabled" do - @user.enable_mfa!("some-seed", "ui_and_api") - user_with_mfa = create(:user, mfa_level: "ui_only") + @user.enable_totp!("some-seed", "ui_and_api") + user_with_mfa = create(:user) + user_with_mfa.enable_totp!("some-seed", "ui_and_api") create(:ownership, rubygem: @rubygem, user: @user) create(:ownership, rubygem: @rubygem, user: user_with_mfa) - visit rubygem_path(@rubygem, as: @user.id) + visit rubygem_path(@rubygem.slug, as: @user.id) assert page.has_no_selector?(".gem__users__mfa-text.mfa-warn") assert page.has_selector?(".gem__users__mfa-text.mfa-info") end test "does not show owners without mfa when not logged in as owner" do - @user.enable_mfa!("some-seed", "ui_and_api") - user_without_mfa = create(:user, mfa_level: "disabled") + @user.enable_totp!("some-seed", "ui_and_api") + user_without_mfa = create(:user) create(:ownership, rubygem: @rubygem, user: @user) create(:ownership, rubygem: @rubygem, user: user_without_mfa) - visit rubygem_path(@rubygem) + visit rubygem_path(@rubygem.slug) assert page.has_no_selector?(".gem__users__mfa-disabled .gem__users a") assert page.has_no_selector?(".gem__users__mfa-text.mfa-warn") @@ -126,7 +163,8 @@ class GemsSystemTest < SystemTest github_link = "http://github.com/user/project" create(:version, number: "3.0.1", rubygem: @rubygem, metadata: { "source_code_uri" => github_link }) - visit rubygem_path(@rubygem) + visit rubygem_path(@rubygem.slug) + assert page.has_selector?(".github-btn") end @@ -134,7 +172,8 @@ class GemsSystemTest < SystemTest github_link = "http://github.com/user/project" create(:version, number: "3.0.1", rubygem: @rubygem, metadata: { "homepage_uri" => github_link }) - visit rubygem_path(@rubygem) + visit rubygem_path(@rubygem.slug) + assert page.has_selector?(".github-btn") end @@ -142,7 +181,66 @@ class GemsSystemTest < SystemTest notgithub_link = "http://notgithub.com/user/project" create(:version, number: "3.0.1", rubygem: @rubygem, metadata: { "homepage_uri" => notgithub_link }) - visit rubygem_path(@rubygem) + visit rubygem_path(@rubygem.slug) + assert page.has_no_selector?(".github-btn") end + + test "shows both mfa headers if latest AND viewed version require MFA" do + @version.update_attribute :metadata, { "rubygems_mfa_required" => "true" } + create(:version, :mfa_required, rubygem: @rubygem, number: "0.1.1") + + visit rubygem_version_path(@rubygem.slug, "0.1.1") + + assert page.has_content? "New versions require MFA" + assert page.has_content? "Version published with MFA" + end + + test "shows 'new' mfa header only if latest requires MFA but viewed version doesn't" do + @version.update_attribute :metadata, { "rubygems_mfa_required" => "true" } + create(:version, rubygem: @rubygem, number: "0.1.1") + + visit rubygem_version_path(@rubygem.slug, "0.1.1") + + assert page.has_content? "New versions require MFA" + refute page.has_content? "Version published with MFA" + end + + test "shows 'version' mfa header only if latest does not require MFA but viewed version does" do + @version.update_attribute :metadata, { "rubygems_mfa_required" => "false" } + create(:version, :mfa_required, rubygem: @rubygem, number: "0.1.1") + + visit rubygem_version_path(@rubygem.slug, "0.1.1") + + refute page.has_content? "New versions require MFA" + assert page.has_content? "Version published with MFA" + end + + test "does not show either mfa header if neither latest or viewed version require MFA" do + @version.update_attribute :metadata, { "rubygems_mfa_required" => "false" } + create(:version, rubygem: @rubygem, number: "0.1.1") + + visit rubygem_version_path(@rubygem.slug, "0.1.1") + + refute page.has_content? "New versions require MFA" + refute page.has_content? "Version published with MFA" + end + + test "shows both mfa headers if MFA enabled for latest version and viewing latest version" do + @version.update_attribute :metadata, { "rubygems_mfa_required" => "true" } + + visit rubygem_path(@rubygem.slug) + + assert page.has_content? "New versions require MFA" + assert page.has_content? "Version published with MFA" + end + + test "shows neither mfa header if MFA disabled for latest version and viewing latest version" do + @version.update_attribute :metadata, { "rubygems_mfa_required" => "false" } + + visit rubygem_path(@rubygem.slug) + + refute page.has_content? "New versions require MFA" + refute page.has_content? "Version published with MFA" + end end diff --git a/test/integration/i18n_test.rb b/test/integration/i18n_test.rb index 33ed59094d0..1212f677ccd 100644 --- a/test/integration/i18n_test.rb +++ b/test/integration/i18n_test.rb @@ -14,6 +14,9 @@ def collect_combined_keys(hash, namespace = nil) locales = Dir.glob("#{locales_path}/*.yml").collect do |file_path| File.basename(file_path, ".yml") end + locales.reject! do |file_path| + file_path.starts_with?("avo.") + end # collecting all locales locale_keys = {} @@ -24,13 +27,16 @@ def collect_combined_keys(hash, namespace = nil) # Using en as reference reference = locale_keys[locales.delete("en")] - assert reference.present? + + assert_predicate reference, :present? locale_keys.each do |locale, keys| missing = reference - keys - assert missing.blank?, "#{locale} locale is missing: #{missing.join(', ')}" + + assert_predicate missing, :blank?, "#{locale} locale is missing: #{missing.join(', ')}" extra = keys - reference - assert extra.blank?, "#{locale} locale has extra: #{extra.join(', ')}" + + assert_predicate extra, :blank?, "#{locale} locale has extra: #{extra.join(', ')}" end end end diff --git a/test/integration/locale_test.rb b/test/integration/locale_test.rb new file mode 100644 index 00000000000..217ebf30c4d --- /dev/null +++ b/test/integration/locale_test.rb @@ -0,0 +1,21 @@ +require "test_helper" + +class LocaleTest < SystemTest + test "html lang attribute is set from locale" do + I18n.available_locales.each do |locale| + visit root_path(locale: locale) + + assert_equal locale.to_s, page.find("html")[:lang] + end + end + + test "locale is switched via locale menu" do + visit root_path + + assert_equal I18n.default_locale.to_s, page.find("html")[:lang] + + click_link "Deutsch" + + assert_equal "de", page.find("html")[:lang] + end +end diff --git a/test/integration/notification_settings_test.rb b/test/integration/notification_settings_test.rb index 712b336101e..9c377ef51b4 100644 --- a/test/integration/notification_settings_test.rb +++ b/test/integration/notification_settings_test.rb @@ -26,16 +26,21 @@ class NotificationSettingsTest < SystemTest assert_unchecked_field notifier_off_radio(ownership1, "owner") assert_checked_field notifier_on_radio(ownership2, "owner") assert_unchecked_field notifier_off_radio(ownership2, "owner") + assert_checked_field notifier_on_radio(ownership1, "ownership_request") + assert_unchecked_field notifier_off_radio(ownership1, "ownership_request") + assert_checked_field notifier_on_radio(ownership2, "ownership_request") + assert_unchecked_field notifier_off_radio(ownership2, "ownership_request") choose notifier_off_radio(ownership1, "push") choose notifier_off_radio(ownership2, "owner") + choose notifier_off_radio(ownership2, "ownership_request") - click_button I18n.t("notifiers.show.update") + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + click_button I18n.t("notifiers.show.update") + end end - assert_changes :mails_count, from: 0, to: 1 do - Delayed::Worker.new.work_off - end + assert_emails 1 assert_equal I18n.t("mailer.notifiers_changed.subject"), last_email.subject @@ -50,6 +55,10 @@ class NotificationSettingsTest < SystemTest assert_unchecked_field notifier_off_radio(ownership1, "owner") assert_unchecked_field notifier_on_radio(ownership2, "owner") assert_checked_field notifier_off_radio(ownership2, "owner") + assert_checked_field notifier_on_radio(ownership1, "ownership_request") + assert_unchecked_field notifier_off_radio(ownership1, "ownership_request") + assert_unchecked_field notifier_on_radio(ownership2, "ownership_request") + assert_checked_field notifier_off_radio(ownership2, "ownership_request") end end diff --git a/test/integration/null_char_param_test.rb b/test/integration/null_char_param_test.rb index 843f674a48a..bc0ebd713ad 100644 --- a/test/integration/null_char_param_test.rb +++ b/test/integration/null_char_param_test.rb @@ -3,11 +3,25 @@ class NullCharParamTest < ActionDispatch::IntegrationTest test "params with null character respond with bad request" do get "/search?utf8=%E2%9C%93&query=php://input%00.&search_submit=%E2%8C%95" + assert_response :bad_request end test "nested params with null character respond with bad request" do get "/search?utf8=%E2%9C%93&query[some]=php://input%00.&search_submit=%E2%8C%95" + + assert_response :bad_request + end + + test "cookie with null character responds with bad request for sign in" do + get "/users/new", headers: { "HTTP_COOKIE" => "remember_token=php://input%00.;rubygems_session=php://input%00." } + + assert_response :bad_request + end + + test "cookie with null character responds with bad request for releases page" do + get "/releases/popular", headers: { "HTTP_COOKIE" => "rubygems_session=php://input%00." } + assert_response :bad_request end end diff --git a/test/integration/oauth_test.rb b/test/integration/oauth_test.rb new file mode 100644 index 00000000000..caebd1ab09e --- /dev/null +++ b/test/integration/oauth_test.rb @@ -0,0 +1,267 @@ +require "test_helper" + +class OAuthTest < ActionDispatch::IntegrationTest + setup do + OmniAuth.config.mock_auth[:github] = OmniAuth::AuthHash.new({ + provider: "github", + uid: "95144751", + info: { + nickname: "jackson-keeling", + email: "jackson.keeling@example.com", + name: "Jackson Keeling", + image: "https://via.placeholder.com/300x300.png", + urls: { + GitHub: "https://github.com/jackson-keeling" + } + }, + credentials: { + token: "9ea49b946a31b705a0168295a0caa195", + expires: false + }, + extra: { + raw_info: { + login: "jackson-keeling", + id: "95144751", + avatar_url: "https://via.placeholder.com/300x300.png", + gravatar_id: "", + url: "https://api.github.com/users/jackson-keeling", + html_url: "https://github.com/jackson-keeling", + followers_url: "https://api.github.com/users/jackson-keeling/followers", + following_url: "https://api.github.com/users/jackson-keeling/following{/other_user}", + gists_url: "https://api.github.com/users/jackson-keeling/gists{/gist_id}", + starred_url: "https://api.github.com/users/jackson-keeling/starred{/owner}{/repo}", + subscriptions_url: "https://api.github.com/users/jackson-keeling/subscriptions", + organizations_url: "https://api.github.com/users/jackson-keeling/orgs", + repos_url: "https://api.github.com/users/jackson-keeling/repos", + events_url: "https://api.github.com/users/jackson-keeling/events{/privacy}", + received_events_url: "https://api.github.com/users/jackson-keeling/received_events", + type: "User", + site_admin: true, + name: "Jackson Keeling", + company: nil, + blog: nil, + location: "Paigeton, Massachusetts", + email: "jackson.keeling@example.com", + hireable: nil, + bio: nil, + public_repos: 263, + public_gists: 658, + followers: 294, + following: 865, + created_at: "2017-03-10T19:49:54+03:00", + updated_at: "2017-04-04T10:32:08+03:00" + } + } + }) + end + + def do_login + get avo_path(params: { a: :b }) + post html_document.at_css("form").attribute("action").value + follow_redirect! + end + + test "sets auth cookie when successful" do + info_data = { + viewer: { + login: "jackson-keeling", + id: "95144751", + organization: { + name: "RubyGems", + login: "rubygems", + viewerIsAMember: true, + teams: { + edges: [ + { node: { slug: "rubygems-org" } }, + { node: { slug: "security" } } + ] + } + } + } + } + stub_github_info_request(info_data) + + do_login + + assert_redirected_to avo_path(params: { a: :b }) + assert_not_nil cookies["rubygems_admin_oauth_github_user"] + follow_redirect! + follow_redirect! + + assert_response :success + assert page.assert_text "jackson-keeling" + + Admin::GitHubUser.admins.sole.tap do |user| + assert user.is_admin + assert_equal [{ slug: "rubygems-org" }, { slug: "security" }], user.teams + assert user.team_member?("rubygems-org") + refute user.team_member?("rubygems-org-not") + assert_equal info_data, user.info_data + end + + delete "/admin/logout" + + assert_redirected_to root_path + assert_empty cookies["rubygems_admin_oauth_github_user"] + end + + test "fails when user is not a member of the rubygems org" do + info_data = { + viewer: { + login: "jackson-keeling", + id: "95144751", + organization: nil + } + } + stub_github_info_request(info_data) + + do_login + + assert_response :forbidden + assert_nil cookies["rubygems_admin_oauth_github"] + assert_equal "Validation failed: Is admin missing rubygems org, Is admin not a member of the rubygems org", response.body + assert_empty Admin::GitHubUser.all + end + + context "with an existing user for the github_id" do + setup do + @existing = FactoryBot.create( + :admin_github_user, + :is_admin + ) + end + + should "login updates info data" do + info_data = { + viewer: { + login: "#{@existing.login}_update", + id: @existing.github_id, + organization: { + name: "RubyGems", + login: "rubygems", + viewerIsAMember: true, + teams: { + edges: [ + { node: { slug: "other-team" } }, + { node: { slug: "rubygems-org" } } + ] + } + } + } + } + stub_github_info_request(info_data) + OmniAuth.config.mock_auth[:github].credentials.token += "_update" + + do_login + + assert_redirected_to avo_path(params: { a: :b }) + assert_not_nil cookies["rubygems_admin_oauth_github_user"] + follow_redirect! + follow_redirect! + + assert_response :success + + Admin::GitHubUser.admins.sole.tap do |user| + assert user.is_admin + assert user.login.ends_with?("_update") + assert_equal @existing.github_id, user.github_id + assert user.oauth_token.ends_with?("_update") + assert_equal [{ slug: "other-team" }, { slug: "rubygems-org" }], user.teams + assert_equal info_data, user.info_data + end + end + + should "login updates to non-admin" do + info_data = { + viewer: { + login: @existing.login, + id: @existing.github_id, + organization: nil + } + } + stub_github_info_request(info_data) + + do_login + + assert_nil cookies["rubygems_admin_oauth_github_user"] + assert_response :forbidden + + Admin::GitHubUser.sole.tap do |user| + refute user.is_admin + assert_empty user.teams + assert_equal info_data, user.info_data + end + end + + context "existing user is not an admin" do + setup do + @existing.update!( + is_admin: false, + info_data: { + viewer: { + login: @existing.login, + id: @existing.github_id, + organization: nil + } + } + ) + end + + should "update to admin" do + info_data = { + viewer: { + login: @existing.login, + id: @existing.github_id, + organization: { + name: "RubyGems", + login: "rubygems", + viewerIsAMember: true, + teams: { + edges: [ + { node: { slug: "rubygems-org" } } + ] + } + } + } + } + stub_github_info_request(info_data) + + do_login + + assert_redirected_to avo_path(params: { a: :b }) + assert_not_nil cookies["rubygems_admin_oauth_github_user"] + follow_redirect! + follow_redirect! + + assert_response :success + + Admin::GitHubUser.sole.tap do |user| + assert user.is_admin + assert_equal info_data, user.info_data + end + end + + should "stay non-admin" do + info_data = { + viewer: { + login: @existing.login, + id: @existing.github_id, + organization: nil + } + } + stub_github_info_request(info_data) + + do_login + + assert_nil cookies["rubygems_admin_oauth_github_user"] + assert_response :forbidden + + Admin::GitHubUser.sole.tap do |user| + refute user.is_admin + assert_empty user.teams + assert_equal info_data, user.info_data + end + end + end + end +end diff --git a/test/integration/oidc/api_key_roles_controller_test.rb b/test/integration/oidc/api_key_roles_controller_test.rb new file mode 100644 index 00000000000..644ff624f0c --- /dev/null +++ b/test/integration/oidc/api_key_roles_controller_test.rb @@ -0,0 +1,173 @@ +require "test_helper" + +class OIDC::ApiKeyRolesControllerIntegrationTest < ActionDispatch::IntegrationTest + setup do + @user = create(:user, remember_token_expires_at: Gemcutter::REMEMBER_FOR.from_now) + post session_path(session: { who: @user.handle, password: PasswordHelpers::SECURE_TEST_PASSWORD }) + + @id_token = create(:oidc_id_token, user: @user) + @api_key_role = @id_token.api_key_role + end + + context "with a verified session" do + setup do + post(authenticate_session_path(verify_password: { password: PasswordHelpers::SECURE_TEST_PASSWORD })) + end + + should "get show" do + get profile_oidc_api_key_role_url(@api_key_role.token) + + assert_response :success + end + + should "get show format json" do + get profile_oidc_api_key_role_url(@api_key_role.token, format: :json) + + assert_response :success + end + + should "get index" do + get profile_oidc_api_key_roles_url + + assert_response :success + end + + should "get new" do + get new_profile_oidc_api_key_role_url + + assert_response :success + end + + should "get new scoped to a rubygem" do + rubygem = create(:rubygem, owners: [@user]) + create(:version, rubygem: rubygem) + get new_profile_oidc_api_key_role_url(rubygem: rubygem.name) + + assert_response :success + page.assert_selector :field, "oidc_api_key_role[name]", with: "Push #{rubygem.name}" + page.assert_selector :select, "Gem Scope", selected: [rubygem.name] + end + + should "get new scoped to a rubygem with a taken name" do + rubygem = create(:rubygem, owners: [@user]) + create(:version, rubygem: rubygem) + create(:oidc_api_key_role, name: "Push #{rubygem.name}", user: @user) + get new_profile_oidc_api_key_role_url(rubygem: rubygem.name) + + assert_response :success + page.assert_selector :field, "oidc_api_key_role[name]", with: "Push #{rubygem.name} 2" + page.assert_selector :select, "Gem Scope", selected: [rubygem.name] + end + + should "get github_actions_workflow" do + get github_actions_workflow_profile_oidc_api_key_role_url(@api_key_role.token) + + assert_response :success + end + + should "get github_actions_workflow with a github actions role" do + provider = create(:oidc_provider, issuer: "https://token.actions.githubusercontent.com") + @api_key_role = create(:oidc_api_key_role, provider:, user: @user).token + get github_actions_workflow_profile_oidc_api_key_role_url(@api_key_role) + + assert_response :success + end + + should "get github_actions_workflow with a github actions role scoped to a gem" do + provider = create(:oidc_provider, issuer: "https://token.actions.githubusercontent.com") + rubygem = create(:rubygem, owners: [@user]) + create(:version, rubygem: rubygem, metadata: { "source_code_uri" => "https://github.com/example/#{rubygem.name}" }) + + @api_key_role = create(:oidc_api_key_role, + user: @user, + provider:, + api_key_permissions: { scopes: ["push_rubygem"], gems: [rubygem.name] }) + get github_actions_workflow_profile_oidc_api_key_role_url(@api_key_role.token) + + assert_response :success + end + + should "get github_actions_workflow with a configured aud" do + provider = create(:oidc_provider, issuer: "https://token.actions.githubusercontent.com") + + @api_key_role = create(:oidc_api_key_role, + user: @user, + provider:, + access_policy: { statements: [{ effect: "allow", conditions: [{ claim: "aud", operator: "string_equals", value: "example.com" }] }] }) + get github_actions_workflow_profile_oidc_api_key_role_url(@api_key_role.token) + + assert_response :success + page.assert_text "audience: example.com" + end + + should "get github_actions_workflow with a configured default aud" do + provider = create(:oidc_provider, issuer: "https://token.actions.githubusercontent.com") + + @api_key_role = create(:oidc_api_key_role, + user: @user, + provider:, + access_policy: { statements: [{ effect: "allow", conditions: [{ claim: "aud", operator: "string_equals", value: "rubygems.org" }] }] }) + get github_actions_workflow_profile_oidc_api_key_role_url(@api_key_role.token) + + assert_response :success + page.assert_no_text "audience:" + end + + should "delete" do + delete profile_oidc_api_key_role_url(@api_key_role.token) + + assert_response :redirect + assert_redirected_to profile_oidc_api_key_roles_path + + follow_redirect! + + page.assert_no_text @api_key_role.token + + get profile_oidc_api_key_role_url(@api_key_role.token) + + assert_response :success + page.assert_selector "h2", text: /This role was deleted .+ ago and can no longer be used/ + + delete profile_oidc_api_key_role_url(@api_key_role.token) + + assert_response :redirect + assert_redirected_to profile_oidc_api_key_roles_path + + follow_redirect! + + page.assert_selector ".flash #flash_error", text: "The role has been deleted." + + get edit_profile_oidc_api_key_role_url(@api_key_role.token) + + assert_response :redirect + assert_redirected_to profile_oidc_api_key_roles_path + + follow_redirect! + + page.assert_selector ".flash #flash_error", text: "The role has been deleted." + end + end + + context "without a verified session" do + should "redirect show to verify" do + get profile_oidc_api_key_role_url(@api_key_role.token) + + assert_response :redirect + assert_redirected_to verify_session_path + end + + should "redirect index to verify" do + get profile_oidc_api_key_roles_url + + assert_response :redirect + assert_redirected_to verify_session_path + end + + should "redirect github_actions_workflow to verify" do + get github_actions_workflow_profile_oidc_api_key_role_url(@api_key_role.token) + + assert_response :redirect + assert_redirected_to verify_session_path + end + end +end diff --git a/test/integration/oidc/id_tokens_controller_test.rb b/test/integration/oidc/id_tokens_controller_test.rb new file mode 100644 index 00000000000..410d4a78b7c --- /dev/null +++ b/test/integration/oidc/id_tokens_controller_test.rb @@ -0,0 +1,44 @@ +require "test_helper" + +class OIDC::IdTokensControllerTest < ActionDispatch::IntegrationTest + setup do + @user = create(:user, remember_token_expires_at: Gemcutter::REMEMBER_FOR.from_now) + post session_path(session: { who: @user.handle, password: PasswordHelpers::SECURE_TEST_PASSWORD }) + + @id_token = create(:oidc_id_token, user: @user) + end + + context "with a verified session" do + setup do + post(authenticate_session_path(verify_password: { password: PasswordHelpers::SECURE_TEST_PASSWORD })) + end + + should "get show" do + get profile_oidc_id_token_url(@id_token) + + assert_response :success + end + + should "get index" do + get profile_oidc_id_tokens_url + + assert_response :success + end + end + + context "without a verified session" do + should "redirect show to verify" do + get profile_oidc_id_token_url(@id_token) + + assert_response :redirect + assert_redirected_to verify_session_path + end + + should "redirect index to verify" do + get profile_oidc_id_tokens_url + + assert_response :redirect + assert_redirected_to verify_session_path + end + end +end diff --git a/test/integration/oidc/pending_trusted_publishers_controller_test.rb b/test/integration/oidc/pending_trusted_publishers_controller_test.rb new file mode 100644 index 00000000000..a5eb8db551d --- /dev/null +++ b/test/integration/oidc/pending_trusted_publishers_controller_test.rb @@ -0,0 +1,156 @@ +require "test_helper" + +class OIDC::PendingTrustedPublishersControllerTest < ActionDispatch::IntegrationTest + setup do + @user = create(:user, remember_token_expires_at: Gemcutter::REMEMBER_FOR.from_now) + post session_path(session: { who: @user.handle, password: PasswordHelpers::SECURE_TEST_PASSWORD }) + + @trusted_publisher = create(:oidc_pending_trusted_publisher, user: @user, rubygem_name: "pending-gem-name") + @expired_trusted_publisher = create(:oidc_pending_trusted_publisher, user: @user, expires_at: 1.day.ago, rubygem_name: "expired-gem-name") + end + + context "with a verified session" do + setup do + post(authenticate_session_path(verify_password: { password: PasswordHelpers::SECURE_TEST_PASSWORD })) + end + + should "get index" do + get profile_oidc_pending_trusted_publishers_url + + assert_response :success + + assert page.has_content?(@trusted_publisher.rubygem_name) + refute page.has_content?(@expired_trusted_publisher.rubygem_name) + end + + should "get new" do + get new_profile_oidc_pending_trusted_publisher_url + + assert_response :success + end + + should "create trusted publisher" do + stub_request(:get, "https://api.github.com/users/example") + .to_return(status: 200, body: { id: "54321" }.to_json, headers: { "Content-Type" => "application/json" }) + + assert_difference("OIDC::PendingTrustedPublisher.count") do + trusted_publisher = build(:oidc_pending_trusted_publisher) + post profile_oidc_pending_trusted_publishers_url, params: { + oidc_pending_trusted_publisher: { + rubygem_name: trusted_publisher.rubygem_name, + trusted_publisher_type: trusted_publisher.trusted_publisher_type, + trusted_publisher_attributes: trusted_publisher.trusted_publisher.as_json + } + } + end + + assert_redirected_to profile_oidc_pending_trusted_publishers_url + end + + should "error creating trusted publisher with type" do + assert_no_difference("OIDC::PendingTrustedPublisher.count") do + post profile_oidc_pending_trusted_publishers_url, params: { + oidc_pending_trusted_publisher: { + rubygem_name: "rubygem1", + trusted_publisher_type: "Hash", + trusted_publisher_attributes: { repository_owner: "example" } + } + } + + assert_response :redirect + assert_equal "Unsupported trusted publisher type", flash[:error] + end + end + + should "error creating trusted publisher with unknown repository owner" do + stub_request(:get, "https://api.github.com/users/example") + .to_return(status: 404, body: { message: "Not Found" }.to_json, headers: { "Content-Type" => "application/json" }) + + assert_no_difference("OIDC::PendingTrustedPublisher.count") do + post profile_oidc_pending_trusted_publishers_url, params: { + oidc_pending_trusted_publisher: { + rubygem_name: "rubygem1", + trusted_publisher_type: OIDC::TrustedPublisher::GitHubAction.polymorphic_name, + trusted_publisher_attributes: { repository_owner: "example" } + } + } + + assert_response :unprocessable_content + assert_equal [ + "Trusted publisher repository name can't be blank", + "Trusted publisher workflow filename can't be blank", + "Trusted publisher repository owner can't be blank" + ].to_sentence, flash[:error] + end + end + + should "error creating invalid trusted publisher" do + stub_request(:get, "https://api.github.com/users/example") + .to_return(status: 200, body: { id: "54321" }.to_json, headers: { "Content-Type" => "application/json" }) + + assert_no_difference("OIDC::PendingTrustedPublisher.count") do + post profile_oidc_pending_trusted_publishers_url, params: { + oidc_pending_trusted_publisher: { + rubygem_name: "rubygem1", + trusted_publisher_type: OIDC::TrustedPublisher::GitHubAction.polymorphic_name, + trusted_publisher_attributes: { repository_name: "rubygem1", repository_owner: "example", workflow_filename: "ci.NO" } + } + } + + assert_response :unprocessable_content + assert_equal ["Trusted publisher workflow filename must end with .yml or .yaml"].to_sentence, flash[:error] + end + end + + should "destroy trusted publisher" do + assert_difference("OIDC::PendingTrustedPublisher.count", -1) do + delete profile_oidc_pending_trusted_publisher_url(@trusted_publisher) + end + + assert_redirected_to profile_oidc_pending_trusted_publishers_url + + assert_raises ActiveRecord::RecordNotFound do + @trusted_publisher.reload + end + end + + should "return not found on destroy for other users trusted publisher" do + trusted_publisher = create(:oidc_pending_trusted_publisher) + assert_no_difference("OIDC::PendingTrustedPublisher.count") do + delete profile_oidc_pending_trusted_publisher_url(trusted_publisher) + + assert_response :not_found + end + end + end + + context "without a verified session" do + should "redirect index to verify" do + get profile_oidc_pending_trusted_publishers_url + + assert_response :redirect + assert_redirected_to verify_session_path + end + + should "redirect new to verify" do + get new_profile_oidc_pending_trusted_publisher_url + + assert_response :redirect + assert_redirected_to verify_session_path + end + + should "redirect create to verify" do + post profile_oidc_pending_trusted_publishers_url + + assert_response :redirect + assert_redirected_to verify_session_path + end + + should "redirect destroy to verify" do + delete new_profile_oidc_pending_trusted_publisher_url + + assert_response :redirect + assert_redirected_to verify_session_path + end + end +end diff --git a/test/integration/oidc/providers_controller_test.rb b/test/integration/oidc/providers_controller_test.rb new file mode 100644 index 00000000000..fb0fd6b35f3 --- /dev/null +++ b/test/integration/oidc/providers_controller_test.rb @@ -0,0 +1,45 @@ +require "test_helper" + +class OIDC::ProvidersControllerTest < ActionDispatch::IntegrationTest + setup do + @user = create(:user, remember_token_expires_at: Gemcutter::REMEMBER_FOR.from_now) + post session_path(session: { who: @user.handle, password: PasswordHelpers::SECURE_TEST_PASSWORD }) + + @id_token = create(:oidc_id_token, user: @user) + @provider = @id_token.provider + end + + context "with a verified session" do + setup do + post(authenticate_session_path(verify_password: { password: PasswordHelpers::SECURE_TEST_PASSWORD })) + end + + should "get show" do + get profile_oidc_provider_url(@provider) + + assert_response :success + end + + should "get index" do + get profile_oidc_providers_url + + assert_response :success + end + end + + context "without a verified session" do + should "redirect show to verify" do + get profile_oidc_provider_url(@provider) + + assert_response :redirect + assert_redirected_to verify_session_path + end + + should "redirect index to verify" do + get profile_oidc_providers_url + + assert_response :redirect + assert_redirected_to verify_session_path + end + end +end diff --git a/test/integration/oidc/rubygem_trusted_publishers_controller_test.rb b/test/integration/oidc/rubygem_trusted_publishers_controller_test.rb new file mode 100644 index 00000000000..e34e554d390 --- /dev/null +++ b/test/integration/oidc/rubygem_trusted_publishers_controller_test.rb @@ -0,0 +1,253 @@ +require "test_helper" + +class OIDC::RubygemTrustedPublishersControllerTest < ActionDispatch::IntegrationTest + setup do + @user = create(:user, remember_token_expires_at: Gemcutter::REMEMBER_FOR.from_now) + post session_path(session: { who: @user.handle, password: PasswordHelpers::SECURE_TEST_PASSWORD }) + + @rubygem = create(:rubygem, owners: [@user]) + create(:version, rubygem: @rubygem) + @trusted_publisher = create(:oidc_rubygem_trusted_publisher, rubygem: @rubygem) + end + + context "with a verified session" do + setup do + post(authenticate_session_path(verify_password: { password: PasswordHelpers::SECURE_TEST_PASSWORD })) + end + + should "respond forbidden for non-owner" do + @rubygem.disown + + get rubygem_trusted_publishers_url(@rubygem.slug) + + assert_response :forbidden + end + + should "get index" do + create(:oidc_rubygem_trusted_publisher, rubygem: @rubygem, + trusted_publisher: create(:oidc_trusted_publisher_github_action, environment: "production")) + get rubygem_trusted_publishers_url(@rubygem.slug) + + assert_response :success + end + + should "get new" do + get new_rubygem_trusted_publisher_url(@rubygem.slug) + + assert_response :success + end + + should "get new for a github rubygem" do + stub_request(:get, "https://api.github.com/repos/example/rubygem1/contents/.github/workflows") + .to_return(status: 200, body: [ + { name: "ci.yml", type: "file" }, + { name: "push_rubygem.yml", type: "file" }, + { name: "push_README.md", type: "file" }, + { name: "push.yml", type: "directory" } + ].to_json, headers: { "Content-Type" => "application/json" }) + + create(:version, rubygem: @rubygem, metadata: { "source_code_uri" => "https://github.com/example/rubygem1" }) + + get new_rubygem_trusted_publisher_url(@rubygem.slug) + + assert_response :success + + page.assert_selector("input[name='oidc_rubygem_trusted_publisher[trusted_publisher_attributes][repository_owner]'][value='example']") + page.assert_selector("input[name='oidc_rubygem_trusted_publisher[trusted_publisher_attributes][repository_name]'][value='rubygem1']") + page.assert_selector("input[name='oidc_rubygem_trusted_publisher[trusted_publisher_attributes][workflow_filename]'][value='push_rubygem.yml']") + end + + should "get new for a github rubygem with no found workflows" do + stub_request(:get, "https://api.github.com/repos/example/rubygem1/contents/.github/workflows") + .to_return(status: 404, body: { message: "Not Found" }.to_json, headers: { "Content-Type" => "application/json" }) + + create(:version, rubygem: @rubygem, metadata: { "source_code_uri" => "https://github.com/example/rubygem1" }) + + get new_rubygem_trusted_publisher_url(@rubygem.slug) + + assert_response :success + + page.assert_selector("input[name='oidc_rubygem_trusted_publisher[trusted_publisher_attributes][repository_owner]'][value='example']") + page.assert_selector("input[name='oidc_rubygem_trusted_publisher[trusted_publisher_attributes][repository_name]'][value='rubygem1']") + end + + should "get new for a github rubygem with invalid github URL" do + assert_no_difference("OIDC::RubygemTrustedPublisher.count") do + create(:version, rubygem: @rubygem, metadata: { "source_code_uri" => "https://github.com/CaioGarcia1" }) + + get new_rubygem_trusted_publisher_url(@rubygem.slug) + + assert_response :success + end + end + + should "create trusted publisher" do + stub_request(:get, "https://api.github.com/users/example") + .to_return(status: 200, body: { id: "54321" }.to_json, headers: { "Content-Type" => "application/json" }) + + assert_difference("OIDC::RubygemTrustedPublisher.count") do + trusted_publisher = build(:oidc_rubygem_trusted_publisher, rubygem: @rubygem) + post rubygem_trusted_publishers_url(@rubygem.slug), params: { + oidc_rubygem_trusted_publisher: { + trusted_publisher_type: trusted_publisher.trusted_publisher_type, + trusted_publisher_attributes: trusted_publisher.trusted_publisher.as_json + } + } + end + + assert_redirected_to rubygem_trusted_publishers_url(@rubygem.slug) + end + + should "create rubygem trusted publisher when trusted publisher already exists" do + stub_request(:get, "https://api.github.com/users/example") + .to_return(status: 200, body: { id: "123456" }.to_json, headers: { "Content-Type" => "application/json" }) + + github_action_trusted_publisher = create(:oidc_trusted_publisher_github_action) + + assert_difference("OIDC::RubygemTrustedPublisher.count") do + post rubygem_trusted_publishers_url(@rubygem.slug), params: { + oidc_rubygem_trusted_publisher: { + trusted_publisher_type: github_action_trusted_publisher.class.polymorphic_name, + trusted_publisher_attributes: github_action_trusted_publisher.as_json + .slice("workflow_filename", "repository_owner", "repository_name").merge("environment" => "") + } + } + end + + assert_redirected_to rubygem_trusted_publishers_url(@rubygem.slug) + end + + should "error creating trusted publisher with type" do + assert_no_difference("OIDC::RubygemTrustedPublisher.count") do + post rubygem_trusted_publishers_url(@rubygem.slug), params: { + oidc_rubygem_trusted_publisher: { + trusted_publisher_type: "Hash", + trusted_publisher_attributes: { repository_owner: "example" } + } + } + + assert_response :redirect + assert_equal "Unsupported trusted publisher type", flash[:error] + end + end + + should "error creating trusted publisher with unknown repository owner" do + stub_request(:get, "https://api.github.com/users/example") + .to_return(status: 404, body: { message: "Not Found" }.to_json, headers: { "Content-Type" => "application/json" }) + + assert_no_difference("OIDC::RubygemTrustedPublisher.count") do + post rubygem_trusted_publishers_url(@rubygem.slug), params: { + oidc_rubygem_trusted_publisher: { + trusted_publisher_type: OIDC::TrustedPublisher::GitHubAction.polymorphic_name, + trusted_publisher_attributes: { repository_owner: "example" } + } + } + + assert_response :unprocessable_content + assert_equal [ + "Trusted publisher repository name can't be blank", + "Trusted publisher workflow filename can't be blank", + "Trusted publisher repository owner can't be blank" + ].to_sentence, flash[:error] + end + end + + should "error creating invalid trusted publisher" do + stub_request(:get, "https://api.github.com/users/example") + .to_return(status: 200, body: { id: "54321" }.to_json, headers: { "Content-Type" => "application/json" }) + + assert_no_difference("OIDC::RubygemTrustedPublisher.count") do + post rubygem_trusted_publishers_url(@rubygem.slug), params: { + oidc_rubygem_trusted_publisher: { + trusted_publisher_type: OIDC::TrustedPublisher::GitHubAction.polymorphic_name, + trusted_publisher_attributes: { repository_name: "rubygem1", repository_owner: "example", workflow_filename: "ci.NO" } + } + } + + assert_response :unprocessable_content + assert_equal ["Trusted publisher workflow filename must end with .yml or .yaml"].to_sentence, flash[:error] + end + end + + should "destroy trusted publisher" do + assert_difference("OIDC::RubygemTrustedPublisher.count", -1) do + delete rubygem_trusted_publisher_url(@rubygem.slug, @trusted_publisher) + end + + assert_redirected_to rubygem_trusted_publishers_url(@rubygem.slug) + + assert_raises ActiveRecord::RecordNotFound do + @trusted_publisher.reload + end + end + end + + context "without a verified session" do + should "redirect index to verify" do + get rubygem_trusted_publishers_url(@rubygem.slug) + + assert_response :redirect + assert_redirected_to verify_session_path + end + + should "redirect new to verify" do + get new_rubygem_trusted_publisher_url(@rubygem.slug) + + assert_response :redirect + assert_redirected_to verify_session_path + end + + should "redirect create to verify" do + post rubygem_trusted_publishers_url(@rubygem.slug) + + assert_response :redirect + assert_redirected_to verify_session_path + end + + should "redirect destroy to verify" do + delete new_rubygem_trusted_publisher_url(@rubygem.slug) + + assert_response :redirect + assert_redirected_to verify_session_path + end + end + + context "when not authorized for rubygem trusted publishing" do + context "not owner" do + setup do + # must be verified before finding out if you have access to this action + post(authenticate_session_path(verify_password: { password: PasswordHelpers::SECURE_TEST_PASSWORD })) + @rubygem.ownerships.destroy_all + end + + should "render forbidden on show" do + get rubygem_trusted_publishers_url(@rubygem.slug) + + assert_response :forbidden + end + + should "render forbidden on new" do + get new_rubygem_trusted_publisher_url(@rubygem.slug) + + assert_response :forbidden + end + + should "render forbidden on create" do + post rubygem_trusted_publishers_url(@rubygem.slug), params: { + oidc_rubygem_trusted_publisher: { # avoid params permit error before authorization check + trusted_publisher_type: "OIDC::TrustedPublisher::GitHubAction", + trusted_publisher_attributes: {} + } + } + + assert_response :forbidden + end + + should "render forbidden on destroy" do + delete new_rubygem_trusted_publisher_url(@rubygem.slug) + + assert_response :forbidden + end + end + end +end diff --git a/test/integration/owner_test.rb b/test/integration/owner_test.rb index 5cc500cc9fd..e13015369cc 100644 --- a/test/integration/owner_test.rb +++ b/test/integration/owner_test.rb @@ -1,6 +1,7 @@ require "test_helper" class OwnerTest < SystemTest + include ActiveJob::TestHelper include RubygemsHelper setup do @@ -10,17 +11,19 @@ class OwnerTest < SystemTest @ownership = create(:ownership, user: @user, rubygem: @rubygem) sign_in_as(@user) - ActionMailer::Base.deliveries.clear end - teardown { reset_session! } - test "adding owner via UI with email" do visit_ownerships_page fill_in "Email / Handle", with: @other_user.email - click_button "Add Owner" + + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + click_button "Add Owner" + end + owners_table = page.find(:css, ".owners__table") + within_element owners_table do assert_selector(:css, "a[href='#{profile_path(@other_user.display_id)}']") end @@ -29,7 +32,8 @@ class OwnerTest < SystemTest assert_cell(@other_user, "Added By", @user.handle) assert_cell(@other_user, "Confirmed At", "") - Delayed::Worker.new.work_off + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob + assert_emails 1 assert_equal "Please confirm the ownership of #{@rubygem.name} gem on RubyGems.org", last_email.subject end @@ -38,18 +42,22 @@ class OwnerTest < SystemTest visit_ownerships_page fill_in "Email / Handle", with: @other_user.handle - click_button "Add Owner" + + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + click_button "Add Owner" + end assert_cell(@other_user, "Confirmed", "Pending") assert_cell(@other_user, "Added By", @user.handle) - Delayed::Worker.new.work_off + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob + assert_emails 1 assert_equal "Please confirm the ownership of #{@rubygem.name} gem on RubyGems.org", last_email.subject end test "owners data is correctly represented" do - @other_user.enable_mfa!(ROTP::Base32.random_base32, :ui_only) + @other_user.enable_totp!(ROTP::Base32.random_base32, :ui_only) create(:ownership, :unconfirmed, user: @other_user, rubygem: @rubygem) visit_ownerships_page @@ -67,6 +75,7 @@ class OwnerTest < SystemTest assert_selector "img[src='/images/x.svg']" end end + assert_cell(@user, "Confirmed At", @ownership.confirmed_at.strftime("%Y-%m-%d %H:%M %Z")) end @@ -76,16 +85,14 @@ class OwnerTest < SystemTest visit_ownerships_page within_element owner_row(@other_user) do - click_button "Remove" + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + click_button "Remove" + end end - owners_table = page.find(:css, ".owners__table") - within_element owners_table do - refute_selector(:css, "a[href='#{profile_path(@other_user)}']") - end + refute page.has_selector? ".owners__table a[href='#{profile_path(@other_user)}']" - ActionMailer::Base.deliveries.clear - Delayed::Worker.new.work_off + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob assert_emails 1 assert_contains last_email.subject, "You were removed as an owner from #{@rubygem.name} gem" @@ -102,47 +109,107 @@ class OwnerTest < SystemTest assert page.has_selector?("a[href='#{profile_path(@user.display_id)}']") assert page.has_selector? "#flash_alert", text: "Can't remove the only owner of the gem" - Delayed::Worker.new.work_off + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob + assert_no_emails end + test "verify using webauthn" do + create_webauthn_credential + + visit sign_in_path + click_button "Authenticate with security device" + find(:css, ".header__popup-link") + + visit rubygem_path(@rubygem.slug) + click_link "Ownership" + + assert page.has_css? "#verify_password_password" + + click_button "Authenticate with security device" + + page.assert_text "add or remove owners" + end + + test "verify failure using webauthn shows error" do + create_webauthn_credential + + visit sign_in_path + click_button "Authenticate with security device" + find(:css, ".header__popup-link") + + visit rubygem_path(@rubygem.slug) + click_link "Ownership" + + assert page.has_css? "#verify_password_password" + + @user.webauthn_credentials.find_each { |c| c.update!(external_id: "a") } + + click_button "Authenticate with security device" + + page.assert_text "Credentials required" + assert page.has_css? "#verify_password_password" + end + test "verify password again after 10 minutes" do visit_ownerships_page travel 15.minutes - visit rubygem_path(@rubygem) + visit rubygem_path(@rubygem.slug) click_link "Ownership" + assert page.has_field? "Password" fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD click_button "Confirm" end test "incorrect password on verify shows error" do - visit rubygem_path(@rubygem) + visit rubygem_path(@rubygem.slug) + click_link "Ownership" + + assert page.has_css? "#verify_password_password" + fill_in "Password", with: "wrong password" + click_button "Confirm" + + assert page.has_selector? "#flash_alert", text: "This request was denied. We could not verify your password." + end + + test "incorrect password error does not persist after correct password" do + visit rubygem_path(@rubygem.slug) click_link "Ownership" + assert page.has_css? "#verify_password_password" fill_in "Password", with: "wrong password" click_button "Confirm" + assert page.has_selector? "#flash_alert", text: "This request was denied. We could not verify your password." + + fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD + click_button "Confirm" + + assert page.has_no_selector? "#flash_alert" end test "clicking on confirmation link confirms the account" do @unconfirmed_ownership = create(:ownership, :unconfirmed, rubygem: @rubygem) - confirmation_link = confirm_rubygem_owners_url(@rubygem, token: @unconfirmed_ownership.token) - visit confirmation_link + confirmation_link = confirm_rubygem_owners_url(@rubygem.slug, token: @unconfirmed_ownership.token) - assert_equal page.current_path, rubygem_path(@rubygem) + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + visit confirmation_link + end + + assert_equal page.current_path, rubygem_path(@rubygem.slug) assert page.has_selector? "#flash_notice", text: "You were added as an owner to #{@rubygem.name} gem" - Delayed::Worker.new.work_off assert_emails 2 owner_added_email_subjects = ActionMailer::Base.deliveries.map(&:subject) + assert_contains owner_added_email_subjects, "You were added as an owner to #{@rubygem.name} gem" assert_contains owner_added_email_subjects, "User #{@unconfirmed_ownership.user.handle} was added as an owner to #{@rubygem.name} gem" end test "clicking on incorrect link shows error" do - confirmation_link = confirm_rubygem_owners_url(@rubygem, token: SecureRandom.hex(20).encode("UTF-8")) + confirmation_link = confirm_rubygem_owners_url(@rubygem.slug, token: SecureRandom.hex(20).encode("UTF-8")) visit confirmation_link assert page.has_content? "Page not found." @@ -151,30 +218,34 @@ class OwnerTest < SystemTest end test "shows ownership link when is owner" do - visit rubygem_path(@rubygem) - assert page.has_selector?("a[href='#{rubygem_owners_path(@rubygem)}']") + visit rubygem_path(@rubygem.slug) + + assert page.has_selector?("a[href='#{rubygem_owners_path(@rubygem.slug)}']") end test "hides ownership link when not owner" do - page.find("a[href='/sign_out']").click + page.click_link(nil, href: "/sign_out") sign_in_as(@other_user) - visit rubygem_path(@rubygem) - refute page.has_selector?("a[href='#{rubygem_owners_path(@rubygem)}']") + visit rubygem_path(@rubygem.slug) + + refute page.has_selector?("a[href='#{rubygem_owners_path(@rubygem.slug)}']") end test "hides ownership link when not signed in" do - page.find("a[href='/sign_out']").click - visit rubygem_path(@rubygem) - refute page.has_selector?("a[href='#{rubygem_owners_path(@rubygem)}']") + page.click_link(nil, href: "/sign_out") + visit rubygem_path(@rubygem.slug) + + refute page.has_selector?("a[href='#{rubygem_owners_path(@rubygem.slug)}']") end test "shows resend confirmation link when unconfirmed" do - page.find("a[href='/sign_out']").click + page.click_link(nil, href: "/sign_out") create(:ownership, :unconfirmed, user: @other_user, rubygem: @rubygem) sign_in_as(@other_user) - visit rubygem_path(@rubygem) - refute page.has_selector?("a[href='#{rubygem_owners_path(@rubygem)}']") - assert page.has_selector?("a[href='#{resend_confirmation_rubygem_owners_path(@rubygem)}']") + visit rubygem_path(@rubygem.slug) + + refute page.has_selector?("a[href='#{rubygem_owners_path(@rubygem.slug)}']") + assert page.has_selector?("a[href='#{resend_confirmation_rubygem_owners_path(@rubygem.slug)}']") end test "deleting unconfirmed owner user" do @@ -185,6 +256,50 @@ class OwnerTest < SystemTest refute page.has_content? @other_user.handle end + test "updating user to maintainer role" do + maintainer = create(:user) + create(:ownership, user: maintainer, rubygem: @rubygem) + + visit_ownerships_page + + within_element owner_row(maintainer) do + click_button "Edit" + end + + select "Maintainer", from: "role" + + click_button "Update" + + assert_cell maintainer, "Role", "Maintainer" + end + + test "editing the ownership of the current user" do + visit_ownerships_page + + within_element owner_row(@user) do + assert_selector "button[disabled]", text: "Edit" + end + end + + test "creating new owner with maintainer role" do + maintainer = create(:user) + + visit_ownerships_page + + fill_in "Handle", with: maintainer.handle + select "Maintainer", from: "role" + + click_button "Add Owner" + + assert_cell maintainer, "Role", "Maintainer" + end + + teardown do + @authenticator&.remove! + Capybara.reset_sessions! + Capybara.use_default_driver + end + private def owner_row(owner) @@ -200,7 +315,7 @@ def assert_cell(user, column, expected) end def visit_ownerships_page - visit rubygem_path(@rubygem) + visit rubygem_path(@rubygem.slug) click_link "Ownership" return unless page.has_css? "#verify_password_password" @@ -213,5 +328,7 @@ def sign_in_as(user) fill_in "Email or Username", with: user.email fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD click_button "Sign in" + + find(:css, ".header__popup-link") end end diff --git a/test/integration/ownership_call_test.rb b/test/integration/ownership_call_test.rb new file mode 100644 index 00000000000..484e3428b78 --- /dev/null +++ b/test/integration/ownership_call_test.rb @@ -0,0 +1,123 @@ +require "test_helper" +require "helpers/adoption_helpers" + +class OwnershipCallsTest < SystemTest + include ActionMailer::TestHelper + include AdoptionHelpers + + setup do + @owner = create(:user) + end + + test "ownership calls listing and pagination on index" do + gems = create_list(:rubygem, 15, owners: [@owner], number: "1.0.0") # rubocop:disable FactoryBot/ExcessiveCreateList + gems.each do |gem| + create(:ownership_call, rubygem: gem, user: @owner) + end + visit ownership_calls_path + + assert_selector :css, ".gems__meter", text: "Displaying ownership calls 1 - 10 of 15 in total" + assert_selector :css, ".gems__gem", count: 10 + end + + test "shows no calls notice if call doesn't exist" do + rubygem = create(:rubygem, owners: [@owner], downloads: 2_000) + create(:version, rubygem: rubygem, created_at: 2.years.ago) + user = create(:user) + visit rubygem_adoptions_path(rubygem.slug, as: user) + + assert page.has_content? "There are no ownership calls for #{rubygem.name}" + end + + test "create ownership call as owner" do + rubygem = create(:rubygem, owners: [@owner], downloads: 2_000) + create(:version, rubygem: rubygem, created_at: 2.years.ago) + visit_rubygem_adoptions_path(rubygem, @owner) + + assert page.has_field? "Note" + create_call("call about _note_ by *owner*.") + + assert_selector :css, "div.ownership__details > p", text: "call about note by owner." + end + + test "shows correct data and formatting about call if exists" do + rubygem = create(:rubygem, owners: [@owner], number: "1.0.0", downloads: 2_000) + create(:ownership_call, rubygem: rubygem, user: @owner, note: "note _italics_ *bold*.") + user = create(:user) + visit rubygem_adoptions_path(rubygem.slug, as: user) + + assert page.has_link? @owner.handle, href: profile_path(@owner) + within "div.ownership__details" do + assert page.has_css? "em", text: "italics" + assert page.has_css? "strong", text: "bold" + end + end + + test "ownership call of less popular gem as user" do + rubygem = create(:rubygem, owners: [@owner], downloads: 2_000) + create(:version, rubygem: rubygem, created_at: 2.years.ago) + user = create(:user) + visit rubygem_path(rubygem.slug, as: user) + + within ".gem__aside > div.t-list__items" do + click_link "Adoption" + end + + assert page.has_content? "There are no ownership calls for #{rubygem.name}" + assert page.has_field? "Note" + assert page.has_button? "Create ownership request" + end + + test "hide adoptions link if popular gem" do + rubygem = create(:rubygem, owners: [@owner], number: "1.0.0", downloads: 20_000) + user = create(:user) + visit rubygem_path(rubygem.slug, as: user) + + refute page.has_selector? "a[href='#{rubygem_adoptions_path(rubygem.slug)}']" + end + + test "show adoptions link if less popular gem" do + user = create(:user) + rubygem = create(:rubygem, owners: [@owner], number: "1.0.0") + create(:ownership_call, rubygem: rubygem, user: @owner) + + visit rubygem_path(rubygem.slug, as: user) + + within ".gem__aside > div.t-list__items" do + assert_selector :css, "a[href='#{rubygem_adoptions_path(rubygem.slug)}']" + end + end + + test "show adoptions link if owner" do + rubygem = create(:rubygem, owners: [@owner], number: "1.0.0", downloads: 20_000) + create(:ownership_call, rubygem: rubygem, user: @owner) + + visit rubygem_path(rubygem.slug, as: @owner) + + within ".gem__aside > div.t-list__items" do + assert_selector :css, "a[href='#{rubygem_adoptions_path(rubygem.slug)}']" + end + end + + test "close ownership call" do + rubygem = create(:rubygem, owners: [@owner], number: "1.0.0") + ownership_call = create(:ownership_call, rubygem: rubygem, user: @owner) + create_list(:ownership_request, 3, :with_ownership_call, rubygem: rubygem, ownership_call: ownership_call) + + visit_rubygem_adoptions_path(rubygem, @owner) + within first("form.button_to") do + click_button "Close" + end + + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob + + assert_emails 3 + end + + private + + def create_call(note) + fill_in "Note", with: note + click_button "Create ownership call" + end +end diff --git a/test/integration/ownership_request_test.rb b/test/integration/ownership_request_test.rb new file mode 100644 index 00000000000..dba4aca0eb7 --- /dev/null +++ b/test/integration/ownership_request_test.rb @@ -0,0 +1,98 @@ +require "test_helper" +require "helpers/adoption_helpers" + +class OwnershipRequestsTest < SystemTest + include ActionMailer::TestHelper + include AdoptionHelpers + + setup do + @owner = create(:user) + end + + test "create ownership request" do + rubygem = create(:rubygem, owners: [@owner], number: "1.0.0") + user = create(:user) + create(:ownership_call, rubygem: rubygem) + visit ownership_calls_path(as: user.id) + click_link "Apply" + + fill_in "Note", with: "request has _italics_ with *bold*." + click_button "Create ownership request" + + within all("div.ownership__details")[1] do + assert page.has_css? "em", text: "italics" + assert page.has_css? "strong", text: "bold" + end + assert page.has_button? "Close" + refute page.has_button? "Approve" + end + + test "approve ownership request by owner" do + rubygem = create(:rubygem, owners: [@owner], number: "1.0.0") + user = create(:user) + create(:ownership_call, rubygem: rubygem) + create(:ownership_request, user: user, rubygem: rubygem) + + visit_rubygem_adoptions_path(rubygem, @owner) + + click_button "Approve" + + assert_enqueued_emails 3 + assert_includes(rubygem.owners, user) + end + + test "close ownership request by requester" do + rubygem = create(:rubygem, owners: [@owner], number: "1.0.0") + user = create(:user) + create(:ownership_call, rubygem: rubygem) + create(:ownership_request, user: user, rubygem: rubygem) + + visit rubygem_adoptions_path(rubygem.slug, as: user.id) + + click_button "Close" + + assert_empty rubygem.ownership_requests + assert_enqueued_emails 0 + end + + test "close ownership request by owner" do + rubygem = create(:rubygem, owners: [@owner], number: "1.0.0") + user = create(:user) + create(:ownership_call, rubygem: rubygem) + create(:ownership_request, user: user, rubygem: rubygem) + + visit_rubygem_adoptions_path(rubygem, @owner) + + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + page.find_by_id("owner_close_request").click + end + + assert_empty rubygem.ownership_requests + assert_emails 1 + assert_equal "Your ownership request was closed.", last_email.subject + end + + test "cannot close all requests as user" do + rubygem = create(:rubygem, owners: [@owner], downloads: 2_000) + create(:version, rubygem: rubygem, created_at: 2.years.ago) + user = create(:user) + create_list(:ownership_request, 3, rubygem: rubygem) + + visit rubygem_adoptions_path(rubygem.slug, as: user.id) + + refute page.has_link? "Close all" + end + + test "close all requests as owner" do + rubygem = create(:rubygem, owners: [@owner], downloads: 2_000) + create(:version, rubygem: rubygem, created_at: 2.years.ago) + create_list(:ownership_request, 3, rubygem: rubygem) + + visit_rubygem_adoptions_path(rubygem, @owner) + + click_button "Close all" + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob + + assert_emails 3 + end +end diff --git a/test/integration/page_params_test.rb b/test/integration/page_params_test.rb index 0c778bb5633..95260aad9cd 100644 --- a/test/integration/page_params_test.rb +++ b/test/integration/page_params_test.rb @@ -1,10 +1,11 @@ require "test_helper" class PageParamsTest < SystemTest - include ESHelper + include SearchKickHelper test "stats with page param more than 10" do visit stats_path(page: "11") + assert redirect_to(stats_path(page: "1")) assert page.has_content? "Stats" assert page.has_content? "Page number is out of range. Redirected to default page." @@ -12,6 +13,7 @@ class PageParamsTest < SystemTest test "search with page more than 100" do visit search_path(page: "102") + assert redirect_to(search_path(page: "1")) assert page.has_content? "search" assert page.has_content? "Page number is out of range. Redirected to default page." @@ -19,6 +21,7 @@ class PageParamsTest < SystemTest test "news with page more than 10" do visit news_path(page: "12") + assert redirect_to(news_path(page: "1")) assert page.has_content? "New Releases — All Gems" assert page.has_content? "Page number is out of range. Redirected to default page." @@ -26,6 +29,7 @@ class PageParamsTest < SystemTest test "popular news with page more than 10" do visit popular_news_path(page: "12") + assert redirect_to(popular_news_path(page: "1")) assert page.has_content? "New Releases — Popular Gems" assert page.has_content? "Page number is out of range. Redirected to default page." @@ -35,23 +39,26 @@ class PageParamsTest < SystemTest create(:rubygem, name: "some", number: "1.0.0") import_and_refresh visit api_v1_search_path(page: "0", query: "some", format: :json) + assert redirect_to(api_v1_search_path(page: "1", query: "some", format: :json)) - refute JSON.parse(page.body).empty? + refute_empty JSON.parse(page.body) end test "api search with page is not a numer" do create(:rubygem, name: "some", number: "1.0.0") import_and_refresh visit api_v1_search_path(page: "foo", query: "some", format: :json) + assert redirect_to(api_v1_search_path(page: "1", query: "some", format: :json)) - refute JSON.parse(page.body).empty? + refute_empty JSON.parse(page.body) end test "api search with page that can't be converted to a number" do create(:rubygem, name: "some", number: "1.0.0") import_and_refresh visit api_v1_search_path(page: { "$acunetix" => "1" }, query: "some", format: :json) + assert redirect_to(api_v1_search_path(page: "1", query: "some", format: :json)) - refute JSON.parse(page.body).empty? + refute_empty JSON.parse(page.body) end end diff --git a/test/integration/pages_test.rb b/test/integration/pages_test.rb new file mode 100644 index 00000000000..25cce82ef58 --- /dev/null +++ b/test/integration/pages_test.rb @@ -0,0 +1,20 @@ +require "test_helper" + +class PagesTest < SystemTest + test "renders existing page" do + visit "/" + click_link "About" + + assert page.has_content? "Welcome to RubyGems.org" + end + + test "gracefully fails on unknown page" do + assert_raises(ActionController::RoutingError) do + visit "/pages/not-existing-one" + end + + assert_raises(ActionController::RoutingError) do + visit "/pages/data.zip" + end + end +end diff --git a/test/integration/password_reset_test.rb b/test/integration/password_reset_test.rb index b745edbe6c5..327ff07a8a0 100644 --- a/test/integration/password_reset_test.rb +++ b/test/integration/password_reset_test.rb @@ -4,8 +4,8 @@ class PasswordResetTest < SystemTest include ActiveJob::TestHelper def password_reset_link - body = ActionMailer::Base.deliveries.last.body.decoded.to_s - link = %r{http://localhost/users([^";]*)}.match(body) + body = ActionMailer::Base.deliveries.last.parts[1].body.decoded.to_s + link = %r{http://localhost(?::\d+)?/password([^";]*)}.match(body) link[0] end @@ -13,9 +13,6 @@ def password_reset_link @user = create(:user, handle: nil) end - # clears session[:password_reset_token] set in edit action - teardown { reset_session! } - def forgot_password_with(email) visit sign_in_path @@ -34,16 +31,15 @@ def forgot_password_with(email) forgot_password_with @user.email visit password_reset_link - expected_path = "/users/#{@user.id}/password/edit" + expected_path = "/password/edit" + assert_equal expected_path, page.current_path, "removes confirmation token from url" fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD click_button "Save this password" - assert_equal dashboard_path, page.current_path - click_link "Sign out" + assert_equal sign_in_path, page.current_path - visit sign_in_path fill_in "Email or Username", with: @user.email fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD click_button "Sign in" @@ -51,7 +47,7 @@ def forgot_password_with(email) assert page.has_content? "Sign out" end - test "resetting a password with a blank password" do + test "resetting a password with a blank or short password" do forgot_password_with @user.email visit password_reset_link @@ -59,8 +55,37 @@ def forgot_password_with(email) fill_in "Password", with: "" click_button "Save this password" - assert page.has_content? "Password can't be blank." - assert page.has_content? "Sign in" + assert page.has_content? "Your password could not be changed. Please try again." + assert page.has_content? "Password can't be blank" + assert page.has_content? "Reset password" + + # try again with short password + fill_in "Password", with: "pass" + click_button "Save this password" + + assert page.has_content? "Password is too short (minimum is 10 characters)" + assert page.has_content? "Reset password" + + # try again with valid password + fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD + click_button "Save this password" + + assert_equal sign_in_path, page.current_path + assert @user.reload.authenticated? PasswordHelpers::SECURE_TEST_PASSWORD + end + + test "resetting a password but waiting too long after token auth" do + forgot_password_with @user.email + + visit password_reset_link + + fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD + + travel 16.minutes do + click_button "Save this password" + + assert page.has_content? "verification has expired. Please verify again." + end end test "resetting a password when signed in" do @@ -79,24 +104,141 @@ def forgot_password_with(email) visit password_reset_link + assert page.has_content?("Sign out") + fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD click_button "Save this password" + assert_equal sign_in_path, page.current_path assert @user.reload.authenticated? PasswordHelpers::SECURE_TEST_PASSWORD + + assert_event Events::UserEvent::PASSWORD_CHANGED, {}, + @user.events.where(tag: Events::UserEvent::PASSWORD_CHANGED).sole end test "restting password when mfa is enabled" do - @user.enable_mfa!(ROTP::Base32.random_base32, :ui_only) + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) forgot_password_with @user.email visit password_reset_link - fill_in "otp", with: ROTP::TOTP.new(@user.mfa_seed).now + refute page.has_content?("Sign out") + + fill_in "otp", with: ROTP::TOTP.new(@user.totp_seed).now click_button "Authenticate" + refute page.has_content?("Sign out") + fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD click_button "Save this password" - assert page.has_content?("Sign out") + assert_equal sign_in_path, page.current_path + assert @user.reload.authenticated? PasswordHelpers::SECURE_TEST_PASSWORD + end + + test "resetting a password when mfa is enabled but mfa session is expired" do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_gem_signin) + forgot_password_with @user.email + + visit password_reset_link + + fill_in "otp", with: ROTP::TOTP.new(@user.totp_seed).now + travel 16.minutes do + click_button "Authenticate" + + assert page.has_content? "Your login page session has expired." + end + end + + test "resetting password when webauthn is enabled" do + create_webauthn_credential + + forgot_password_with @user.email + + visit password_reset_link + + assert page.has_content? "Multi-factor authentication" + assert page.has_content? "Security Device" + assert_not_nil page.find(".js-webauthn-session--form")[:action] + + click_on "Authenticate with security device" + + fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD + click_button "Save this password" + + assert page.has_content?("Sign in") + assert_equal sign_in_path, page.current_path + assert @user.reload.authenticated? PasswordHelpers::SECURE_TEST_PASSWORD + end + + test "resetting password when webauthn is enabled using recovery codes" do + create_webauthn_credential + + forgot_password_with @user.email + + visit password_reset_link + + refute page.has_content? "Sign out" + assert page.has_content? "Multi-factor authentication" + assert page.has_content? "Security Device" + assert page.has_content? "Recovery code" + assert_not_nil page.find(".js-webauthn-session--form")[:action] + + fill_in "otp", with: @mfa_recovery_codes.first + click_button "Authenticate" + + fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD + click_button "Save this password" + + assert page.has_content?("Sign in") + assert_equal sign_in_path, page.current_path + assert @user.reload.authenticated? PasswordHelpers::SECURE_TEST_PASSWORD + end + + test "resetting password with pending email change" do + visit sign_in_path + + email = @user.email + new_email = "hijack@example.com" + + fill_in "Email or Username", with: email + fill_in "Password", with: @user.password + click_button "Sign in" + + visit edit_profile_path + + fill_in "user_handle", with: "username" + fill_in "Email address", with: new_email + fill_in "Password", with: @user.password + perform_enqueued_jobs { click_button "Update" } + + assert_equal new_email, @user.reload.unconfirmed_email + + click_link "Sign out" + + forgot_password_with email + + assert_nil @user.reload.unconfirmed_email + + token = /edit\?token=(.+)$/.match(password_reset_link)[1] + visit update_email_confirmations_path(token: token) + + assert @user.reload.authenticated? PasswordHelpers::SECURE_TEST_PASSWORD + assert_equal email, @user.email + end + + test "resetting password of soft-deleted user" do + @user.update!(deleted_at: Time.zone.now, email: "deleted+#{@user.id}@rubygems.org") + + forgot_password_with @user.email + + assert_empty ActionMailer::Base.deliveries + page.assert_text "instructions for changing your password" + end + + teardown do + @authenticator&.remove! + Capybara.reset_sessions! + Capybara.use_default_driver end end diff --git a/test/integration/profile_test.rb b/test/integration/profile_test.rb index 84e895d745f..34540ceee86 100644 --- a/test/integration/profile_test.rb +++ b/test/integration/profile_test.rb @@ -1,6 +1,8 @@ require "test_helper" class ProfileTest < SystemTest + include ActiveJob::TestHelper + setup do @user = create(:user, email: "nick@example.com", password: PasswordHelpers::SECURE_TEST_PASSWORD, handle: "nick1", mail_fails: 1) end @@ -12,14 +14,20 @@ def sign_in click_button "Sign in" end + def sign_out + page.driver.browser.clear_cookies # rack-test specific + visit "/" + end + test "changing handle" do sign_in visit profile_path("nick1") + assert page.has_content? "nick1" click_link "Edit Profile" - fill_in "Username", with: "nick2" + fill_in "user_handle", with: "nick2" fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD click_button "Update" @@ -33,7 +41,7 @@ def sign_in visit profile_path("nick1") click_link "Edit Profile" - fill_in "Username", with: "nick2" + fill_in "user_handle", with: "nick2" fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD click_button "Update" @@ -45,7 +53,7 @@ def sign_in visit profile_path("nick1") click_link "Edit Profile" - fill_in "Username", with: "nick1" * 10 + fill_in "user_handle", with: "nick1" * 10 fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD click_button "Update" @@ -61,14 +69,20 @@ def sign_in fill_in "Email address", with: "nick2@example.com" fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD - click_button "Update" + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + click_button "Update" + end assert page.has_selector? "input[value='nick@example.com']" - assert page.has_selector? "#flash_notice", text: "You will receive "\ - "an email within the next few minutes. It contains instructions "\ - "for confirming your new email address." + assert page.has_selector? "#flash_notice", text: "You will receive " \ + "an email within the next few minutes. It contains instructions " \ + "for confirming your new email address." + + assert_event Events::UserEvent::EMAIL_ADDED, { email: "nick2@example.com" }, + @user.events.where(tag: Events::UserEvent::EMAIL_ADDED).sole link = last_email_link + assert_not_nil link assert_changes -> { @user.reload.mail_fails }, from: 1, to: 0 do @@ -76,29 +90,40 @@ def sign_in assert page.has_selector? "#flash_notice", text: "Your email address has been verified" visit edit_profile_path + assert page.has_selector? "input[value='nick2@example.com']" end + + assert_event Events::UserEvent::EMAIL_VERIFIED, { email: "nick2@example.com" }, + @user.events.where(tag: Events::UserEvent::EMAIL_VERIFIED).sole end - test "disabling email on profile" do + test "enabling email on profile" do + # email is hidden at public profile by default + visit profile_path("nick1") + + refute page.has_content?("Email Me") + sign_in visit profile_path("nick1") click_link "Edit Profile" fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD - check "Hide email in public profile" + check "Show email in public profile" click_button "Update" + sign_out visit profile_path("nick1") - refute page.has_content?("Email Me") + + assert page.has_content?("Email Me") end - test "adding Twitter username" do + test "adding X(formerly Twitter) username" do sign_in visit profile_path("nick1") click_link "Edit Profile" - fill_in "Twitter username", with: "nick1" + fill_in "user_twitter_username", with: "nick1" fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD click_button "Update" @@ -117,8 +142,8 @@ def sign_in fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD click_button "Confirm" - assert page.has_content? "Your account deletion request has been enqueued."\ - " We will send you a confirmation mail when your request has been processed." + assert page.has_content? "Your account deletion request has been enqueued. " \ + "We will send you a confirmation mail when your request has been processed." end test "deleting profile multiple times" do @@ -131,10 +156,53 @@ def sign_in sign_in visit delete_profile_path + 2.times { perform_enqueued_jobs } + fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD click_button "Confirm" - Delayed::Worker.new.work_off - assert_empty Delayed::Job.all + assert_no_enqueued_jobs + end + + test "seeing ownership calls and requests" do + rubygem = create(:rubygem, owners: [@user], number: "1.0.0") + requested_gem = create(:rubygem, number: "2.0.0") + create(:ownership_call, rubygem: rubygem, user: @user, note: "special note") + create(:ownership_request, rubygem: requested_gem, user: @user, note: "request note") + + sign_in + visit profile_path("nick1") + click_link "Adoptions" + + assert page.has_link?(rubygem.name, href: "/gems/#{rubygem.name}") + assert page.has_content? "special note" + assert page.has_content? "request note" + end + + test "seeing the gems ordered by downloads" do + create(:rubygem, owners: [@user], number: "1.0.0", downloads: 5) + create(:rubygem, owners: [@user], number: "1.0.0", downloads: 2) + create(:rubygem, owners: [@user], number: "1.0.0", downloads: 7) + + sign_in + visit profile_path("nick1") + + downloads = page.all(".gems__gem__downloads__count") + + assert_equal("7 Downloads", downloads[0].text) + assert_equal("5 Downloads", downloads[1].text) + assert_equal("2 Downloads", downloads[2].text) + end + + test "seeing the latest version when there is a newer previous version" do + create(:rubygem, owners: [@user], number: "1.0.1") + create(:version, rubygem: Rubygem.first, number: "0.0.2") + + sign_in + visit profile_path("nick1") + + version = page.find(".gems__gem__version").text + + assert_equal("1.0.1", version) end end diff --git a/test/integration/push_test.rb b/test/integration/push_test.rb index 795acc90b7d..b3ec204aa0f 100644 --- a/test/integration/push_test.rb +++ b/test/integration/push_test.rb @@ -1,27 +1,37 @@ require "test_helper" class PushTest < ActionDispatch::IntegrationTest + include ActiveJob::TestHelper + setup do Dir.chdir(Dir.mktmpdir) @key = "12345" @user = create(:user) - create(:api_key, user: @user, key: @key, push_rubygem: true) + create(:api_key, owner: @user, key: @key, scopes: %i[push_rubygem]) end test "pushing a gem" do build_gem "sandworm", "1.0.0" push_gem "sandworm-1.0.0.gem" + assert_response :success get rubygem_path("sandworm") + assert_response :success assert page.has_content?("sandworm") assert page.has_content?("1.0.0") assert page.has_content?("Pushed by") css = %(div.gem__users a[alt=#{@user.handle}]) + assert page.has_css?(css, count: 2) + + assert_equal Digest::MD5.hexdigest(<<~INFO), Rubygem.find_by!(name: "sandworm").versions.sole.info_checksum + --- + 1.0.0 |checksum:#{Digest::SHA256.hexdigest File.binread('sandworm-1.0.0.gem')} + INFO end test "push a new version of a gem" do @@ -31,14 +41,116 @@ class PushTest < ActionDispatch::IntegrationTest build_gem "sandworm", "2.0.0" push_gem "sandworm-2.0.0.gem" + assert_response :success + refute_nil RubygemFs.instance.get("gems/sandworm-2.0.0.gem") + refute_nil RubygemFs.instance.get("quick/Marshal.4.8/sandworm-2.0.0.gemspec.rz") + assert_equal({ checksum_sha256: rubygem.versions.find_by!(full_name: "sandworm-2.0.0").sha256, key: "gems/sandworm-2.0.0.gem" }, + RubygemFs.instance.head("gems/sandworm-2.0.0.gem")) + + spec = Gem::Package.new("sandworm-2.0.0.gem").spec + spec.abbreviate + spec.sanitize + spec_checksum = Digest::SHA256.base64digest Gem.deflate Marshal.dump spec + + assert_equal({ checksum_sha256: spec_checksum, key: "quick/Marshal.4.8/sandworm-2.0.0.gemspec.rz" }, + RubygemFs.instance.head("quick/Marshal.4.8/sandworm-2.0.0.gemspec.rz")) + get rubygem_path("sandworm") + assert_response :success assert page.has_content?("sandworm") assert page.has_content?("2.0.0") end + test "pushing a new version of a gem with a trusted publisher" do + rubygem = create(:rubygem, name: "sandworm", number: "1.0.0") + create(:ownership, rubygem: rubygem, user: @user) + + rubygem_trusted_publisher = create(:oidc_rubygem_trusted_publisher, rubygem: rubygem) + + @key = "543321" + create(:api_key, owner: rubygem_trusted_publisher.trusted_publisher, key: @key, scopes: %i[push_rubygem]) + + build_gem "sandworm", "2.0.0" + + push_gem "sandworm-2.0.0.gem" + + assert_response :success + + perform_enqueued_jobs + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob + + email = ActionMailer::Base.deliveries.last + + assert_equal "Gem sandworm (2.0.0) pushed to RubyGems.org", email.subject + assert_equal [@user.email], email.to + email_body = Capybara.string(email.body.raw_source) + + email_body.assert_text("Pushed by trusted publisher") + email_body.assert_text(rubygem_trusted_publisher.trusted_publisher.name) + + assert_event Events::UserEvent::EMAIL_SENT, { + to: @user.email, from: "no-reply@mailer.rubygems.org", subject: email.subject, + message_id: email.message_id, mailer: "mailer", action: "gem_pushed" + }, @user.events.where(tag: Events::UserEvent::EMAIL_SENT).sole + + get rubygem_path("sandworm") + + assert_response :success + page.assert_text("Pushed by") + page.assert_selector(:xpath, ".//img[@title=#{rubygem_trusted_publisher.trusted_publisher.name.inspect}]") + end + + test "pushing a new gem with a pending trusted publisher" do + pending_trusted_publisher = create(:oidc_pending_trusted_publisher, rubygem_name: "sandworm", user: @user) + + @key = "543321" + create(:api_key, owner: pending_trusted_publisher.trusted_publisher, key: @key, scopes: %i[push_rubygem]) + + build_gem "sandworm", "2.0.0" + + push_gem "sandworm-2.0.0.gem" + + assert_response :success + + get rubygem_path("sandworm") + + assert_response :success + page.assert_text("Pushed by") + page.assert_selector(:xpath, ".//img[@title=#{pending_trusted_publisher.trusted_publisher.name.inspect}]") + + rubygem = Rubygem.find_by!(name: "sandworm") + + assert rubygem.owned_by?(@user) + assert rubygem.oidc_rubygem_trusted_publishers.exists?(trusted_publisher: pending_trusted_publisher.trusted_publisher) + end + + test "pushing a new gem with a pending trusted publisher case insensitive" do + pending_trusted_publisher = create(:oidc_pending_trusted_publisher, rubygem_name: "SaNdWoRm", user: @user) + + @key = "543321" + create(:api_key, owner: pending_trusted_publisher.trusted_publisher, key: @key, scopes: %i[push_rubygem]) + + build_gem "sandworm", "2.0.0" + + push_gem "sandworm-2.0.0.gem" + + assert_response :success + + get rubygem_path("sandworm") + + assert_response :success + page.assert_text("Pushed by") + page.assert_selector(:xpath, ".//img[@title=#{pending_trusted_publisher.trusted_publisher.name.inspect}]") + + rubygem = Rubygem.find_by!(name: "sandworm") + + assert rubygem.owned_by?(@user) + assert rubygem.oidc_rubygem_trusted_publishers.exists?(trusted_publisher: pending_trusted_publisher.trusted_publisher) + end + test "pushing a gem with a known dependency" do rubygem = create(:rubygem, name: "crysknife", number: "1.0.0") @@ -48,7 +160,10 @@ class PushTest < ActionDispatch::IntegrationTest push_gem "sandworm-1.0.0.gem" + assert_response :success + get rubygem_path("sandworm") + assert_response :success assert page.has_content?("crysknife") assert page.has_content?("> 0") @@ -61,41 +176,421 @@ class PushTest < ActionDispatch::IntegrationTest push_gem "sandworm-1.0.0.gem" + assert_response :success + get rubygem_path("sandworm") + assert_response :success assert page.has_content?("mauddib") assert page.has_content?("> 1") end + test "pushing a gem with a new platform for the same version" do + rubygem = create(:rubygem, name: "sandworm") + create(:ownership, rubygem: rubygem, user: @user) + create(:version, number: "1.0.0", platform: "ruby", rubygem: rubygem) + create(:version, number: "1.0.0", platform: "java", rubygem: rubygem) + + build_gem "sandworm", "1.0.0" do |gemspec| + gemspec.platform = "universal-darwin-19" + end + + push_gem "sandworm-1.0.0-universal-darwin-19.gem" + + assert_response :success + + version = rubygem.versions.find_by!(number: "1.0.0", platform: "universal-darwin-19") + + assert_equal "universal-darwin-19", version.platform + assert_equal "sandworm-1.0.0-universal-darwin-19", version.full_name + assert_equal "sandworm-1.0.0-universal-darwin-19", version.gem_full_name + end + + test "pushing a gem with a new original platform for the same version and platform" do + rubygem = create(:rubygem, name: "sandworm") + create(:ownership, rubygem: rubygem, user: @user) + create(:version, number: "1.0.0", platform: "universal-darwin-19", rubygem: rubygem) + + build_gem "sandworm", "1.0.0" do |gemspec| + gemspec.platform = "universal-darwin19" + end + + push_gem "sandworm-1.0.0-universal-darwin-19.gem" + + assert_response :forbidden + assert_equal "There was a problem saving your gem: " \ + "Gem full name sandworm-1.0.0-universal-darwin-19 already exists" \ + ", A version already exists with this number and resolved platform [\"universal-darwin-19\"]", + response.body + end + + test "pushing a signed gem" do + push_gem gem_file("valid_signature-0.0.0.gem", &:path) + + assert_response :success + + get rubygem_path("valid_signature") + + assert_response :success + + assert page.has_content?("Signature validity period") + assert page.has_content?("August 31, 2021") + assert page.has_content?("August 07, 2121") + refute page.has_content?("(expired)") + + travel_to Time.zone.local(2121, 8, 8) + get rubygem_path("valid_signature") + + assert page.has_content?("(expired)") + end + test "push errors bubble out" do push_gem Rails.root.join("test", "gems", "bad-characters-1.0.0.gem") - assert_response :unprocessable_entity + assert_response :unprocessable_content assert_match(/cannot process this gem/, response.body) + assert_nil RubygemFs.instance.get("gems/bad-characters-1.0.0.gem") + assert_nil RubygemFs.instance.get("quick/Marshal.4.8/bad-characters-1.0.0.gemspec.rz") + end + + test "push errors don't save files" do + build_gem "sandworm", "1.0.0" + + assert_nil Rubygem.find_by(name: "sandworm") + + # Error on empty authors now happens in a different place, + # but test what would happen if marshal dumping failed + Gem::Specification.any_instance.stubs(:_dump).raises(NoMethodError) + push_gem "sandworm-1.0.0.gem" + + assert_response :unprocessable_content + assert_match(/Please try rebuilding it and installing it locally to make sure it's valid./, response.body) + + assert_nil Rubygem.find_by(name: "sandworm") + assert_nil Version.find_by(full_name: "sandworm-1.0.0") + assert_nil RubygemFs.instance.get("gems/sandworm-1.0.0.gem") + assert_nil RubygemFs.instance.get("quick/Marshal.4.8/sandworm-1.0.0.gemspec.rz") end test "republish a yanked version" do rubygem = create(:rubygem, name: "sandworm", owners: [@user]) - create(:version, number: "1.0.0", indexed: false, rubygem: rubygem) + version = create(:version, number: "1.0.0", rubygem: rubygem) + create(:deletion, version:) build_gem "sandworm", "1.0.0" push_gem "sandworm-1.0.0.gem" + assert_response :conflict assert_match(/A yanked version already exists \(sandworm-1.0.0\)/, response.body) end test "republish a yanked version by a different owner" do rubygem = create(:rubygem, name: "sandworm") - create(:version, number: "1.0.0", indexed: false, rubygem: rubygem) + version = create(:version, number: "1.0.0", rubygem: rubygem) + create(:deletion, version:) build_gem "sandworm", "1.0.0" push_gem "sandworm-1.0.0.gem" + assert_response :conflict assert_match(/A yanked version pushed by a previous owner of this gem already exists \(sandworm-1.0.0\)/, response.body) end + test "republish an indexed version" do + build_gem "sandworm", "1.0.0" + + push_gem "sandworm-1.0.0.gem" + + assert_response :success + + assert_enqueued_jobs 0 do + push_gem "sandworm-1.0.0.gem" + end + + assert_response :success + assert_equal("Gem was already pushed: sandworm (1.0.0)", response.body) + end + + test "republish a version where the gem is un-indexed but not yanked" do + build_gem "sandworm", "1.0.0" + + Pusher.any_instance.stubs(:after_write) + + push_gem "sandworm-1.0.0.gem" + + Pusher.any_instance.unstub(:after_write) + + assert_enqueued_jobs 0 do + push_gem "sandworm-1.0.0.gem" + end + + assert_response :conflict + assert_equal( + "It appears that sandworm-1.0.0 did not finish pushing.\n" \ + "Please contact support@rubygems.org for assistance if you pushed this gem more than a minute ago.", + response.body + ) + end + + test "publishing a gem with ceritifcate but not signatures" do + build_gem "sandworm", "2.0.0" do |gemspec| + gemspec.cert_chain = [File.read(File.expand_path("../certs/chain.pem", __dir__))] + end + + push_gem "sandworm-2.0.0.gem" + + assert_response :forbidden + assert_match(/You have added cert_chain in gemspec but signature was empty/, response.body) + end + + setup do + @act = ENV["HOOK_RELAY_ACCOUNT_ID"] + @id = ENV["HOOK_RELAY_HOOK_ID"] + ENV["HOOK_RELAY_ACCOUNT_ID"] = "act" + ENV["HOOK_RELAY_HOOK_ID"] = "id" + end + + teardown do + ENV["HOOK_RELAY_ACCOUNT_ID"] = @act + ENV["HOOK_RELAY_HOOK_ID"] = @id + end + + test "publishing a gem with webhook subscribers" do + hook = create(:global_web_hook) + + build_gem "sandworm", "2.0.0" + push_gem "sandworm-2.0.0.gem" + + assert_response :success + + stub_request(:post, "https://api.hookrelay.dev/hooks/act/id/webhook_id-#{hook.id}").with( + headers: { + "Content-Type" => "application/json", + "HR_TARGET_URL" => hook.url, + "HR_MAX_ATTEMPTS" => "3" + } + ).and_return(status: 200, body: { id: :id123 }.to_json) + perform_enqueued_jobs only: NotifyWebHookJob + + assert_predicate hook.reload.failure_count, :zero? + + post hook_relay_report_api_v1_web_hooks_path, + params: { + attempts: 3, + account_id: "act", + hook_id: "id", + id: "01GTE93BNWPD0VF0V4QCJ7NDSF", + created_at: "2023-03-01T09:47:18Z", + request: { + body: "{}", + headers: { + Authorization: "51bf53d06ac382585b83e6f3241c950710ee53368e948ac309b7869c974338e9", + "User-Agent": "rest-client/2.1.0 (darwin22 arm64) ruby/3.2.1p31", + Accept: "*/*", + "Content-Type": "application/json" + }, + target_url: "https://example.com/rubygem0" + }, + report_url: "https://rubygems.org/api/v1/web_hooks/hook_relay_report", + max_attempts: 3, + status: "success", + last_attempted_at: "2023-03-01T09:50:31+00:00", + stream: "hook:act:id:webhook_id-#{hook.id}", + completed_at: "2023-03-01T09:50:31+00:00" + }, + as: :json + + assert_response :success + perform_enqueued_jobs only: HookRelayReportJob + + assert_predicate hook.reload.failure_count, :zero? + assert_equal 1, hook.successes_since_last_failure + assert_equal 0, hook.failures_since_last_success + assert_equal "2023-03-01T09:50:31+00:00".to_datetime, hook.last_success + + post hook_relay_report_api_v1_web_hooks_path, + params: { + attempts: 3, + account_id: "act", + hook_id: "id", + id: "01GTE93BNWPD0VF0V4QCJ7NDSF", + created_at: "2023-03-01T09:47:18Z", + request: { + body: "{}", + headers: { + Authorization: "51bf53d06ac382585b83e6f3241c950710ee53368e948ac309b7869c974338e9", + "User-Agent": "rest-client/2.1.0 (darwin22 arm64) ruby/3.2.1p31", + Accept: "*/*", + "Content-Type": "application/json" + }, + target_url: "https://example.com/rubygem0" + }, + report_url: "https://rubygems.org/api/v1/web_hooks/hook_relay_report", + max_attempts: 3, + status: "failure", + last_attempted_at: "2023-03-01T09:50:31+00:00", + stream: "hook:act:id:webhook_id-#{hook.id}", + failure_reason: "Exhausted attempts", + completed_at: "2023-03-01T09:51:31+00:00" + }, + as: :json + + assert_response :success + perform_enqueued_jobs only: HookRelayReportJob + + assert_equal 1, hook.reload.failure_count + assert_equal 0, hook.successes_since_last_failure + assert_equal 1, hook.failures_since_last_success + assert_equal "2023-03-01T09:50:31+00:00".to_datetime, hook.last_success + assert_equal "2023-03-01T09:51:31+00:00".to_datetime, hook.last_failure + end + + context "with specially crafted gemspecs" do + should "not allow overwriting gem with -\\d in name" do + create(:version, number: "2.0", rubygem: create(:rubygem, name: "book-2")) + + build_gem_raw(file_name: "malicious.gem", spec: <<~YAML) + --- !ruby/hash-with-ivars:Gem::Specification + ivars: + '@name': book + '@version': '2-2.0' + '@platform': 'not_ruby' + '@original_platform': 'not-ruby' + '@new_platform': ruby + '@summary': 'malicious' + '@authors': [test@example.com] + YAML + + push_gem "malicious.gem" + + aggregate_assertions "should fail to push" do + assert_response :forbidden + + assert_nil Rubygem.find_by(name: "book") + assert_nil RubygemFs.instance.get("gems/book-2-2.0.gem") + assert_nil RubygemFs.instance.get("quick/Marshal.4.8/book-2-2.0.gemspec.rz") + end + end + + should "not allow overwriting platform gem" do + create(:version, number: "2.0", platform: "universal-darwin-19", rubygem: create(:rubygem, name: "book")) + + build_gem_raw(file_name: "malicious.gem", spec: <<~YAML) + --- !ruby/hash-with-ivars:Gem::Specification + ivars: + '@name': book-2.0-universal-darwin + '@version': '19' + '@platform': 'not_ruby' + '@original_platform': 'not-ruby' + '@new_platform': ruby + '@summary': 'malicious' + '@authors': [test@example.com] + YAML + + push_gem "malicious.gem" + + aggregate_assertions "should fail to push" do + assert_response :forbidden + + assert_nil Rubygem.find_by(name: "book-2.0-universal-darwin") + assert_nil RubygemFs.instance.get("gems/book-2.0-universal-darwin-19.gem") + assert_nil RubygemFs.instance.get("quick/Marshal.4.8/book-2.0-universal-darwin-19.gemspec.rz") + end + end + + context "does not allow pushing a gem where the file name does not match the version full_name" do + should "fail when original platform is a ruby Gem::Platform" do + build_gem_raw(file_name: "malicious.gem", spec: <<~YAML) + --- !ruby/object:Gem::Specification + specification_version: 100 + name: book + version: '1' + platform: !ruby/object:Gem::Platform + os: ruby + summary: 'malicious' + authors: [test@example.com] + YAML + push_gem "malicious.gem" + + aggregate_assertions "should fail to push" do + assert_response :conflict + + assert_nil Rubygem.find_by(name: "book") + assert_nil RubygemFs.instance.get("gems/book-1-ruby.gem") + assert_nil RubygemFs.instance.get("quick/Marshal.4.8/book-1-ruby.gemspec.rz") + end + end + + should "fail when original platform is an array that resolves to a platform of ruby" do + build_gem_raw(file_name: "malicious.gem", spec: <<~YAML) + --- !ruby/object:Gem::Specification + specification_version: 100 + name: book + version: '1' + platform: [ruby] + summary: 'malicious' + authors: [test@example.com] + YAML + push_gem "malicious.gem" + + assert_response :forbidden + end + end + + should "fail fast when spec.name is not a string" do + build_gem_raw(file_name: "malicious.gem", spec: <<~YAML) + --- !ruby/object:Gem::Specification + name: !ruby/object:Gem::Version + version: [] + version: '1' + summary: 'malicious' + authors: [test@example.com] + YAML + push_gem "malicious.gem" + + assert_response :unprocessable_content + end + + should "fail when spec.platform is invalid" do + build_gem_raw(file_name: "malicious.gem", spec: <<~YAML) + --- !ruby/hash-with-ivars:Gem::Specification + ivars: + '@name': book + '@version': '1' + '@new_platform': !ruby/object:Gem::Platform + os: "../../../../../etc/passwd" + '@original_platform': ruby + '@summary': 'malicious' + '@authors': [test@example.com] + YAML + push_gem "malicious.gem" + + assert_response :forbidden + end + + should "fail when spec.date cannot Marshal.dump" do + build_gem_raw(file_name: "malicious.gem", spec: <<~YAML) + --- !ruby/object:Gem::Specification + specification_version: 100 + name: book + version: '1' + platform: ruby + summary: 'malicious' + authors: [test@example.com] + date: !ruby/object:Time + a: 1 + YAML + + capture_io do + push_gem "malicious.gem" + end + + assert_response :unprocessable_content + end + end + def push_gem(path) post api_v1_rubygems_path, env: { "RAW_POST_DATA" => File.read(path) }, @@ -104,6 +599,9 @@ def push_gem(path) end teardown do + RubygemFs.mock! Dir.chdir(Rails.root) end + + make_my_diffs_pretty! end diff --git a/test/integration/rack_attack_test.rb b/test/integration/rack_attack_test.rb index 1f2372055da..a2aeb7b5a69 100644 --- a/test/integration/rack_attack_test.rb +++ b/test/integration/rack_attack_test.rb @@ -41,7 +41,7 @@ class RackAttackTest < ActionDispatch::IntegrationTest stay_under_limit_for("clearance/ip") stay_under_email_limit_for("password/email") - post "/passwords", + post "/password", params: { password: { email: @user.email } }, headers: { REMOTE_ADDR: @ip_address } @@ -56,18 +56,30 @@ class RackAttackTest < ActionDispatch::IntegrationTest params: { email_confirmation: { email: @user.email } }, headers: { REMOTE_ADDR: @ip_address } follow_redirect! + + assert_response :success + end + + should "allow email confirmation resend via unconfirmed" do + stay_under_limit_for("clearance/ip/1") + stay_under_email_limit_for("email_confirmations/email") + + patch "/email_confirmations/unconfirmed", + headers: { REMOTE_ADDR: @ip_address } + follow_redirect! + assert_response :success end context "owners requests" do setup do - cookies[:remember_token] = @user.remember_token + post session_path(session: { who: @user.handle, password: PasswordHelpers::SECURE_TEST_PASSWORD }) @rubygem = create(:rubygem) create(:ownership, :unconfirmed, rubygem: @rubygem, user: @user) end teardown do - cookies[:remember_token] = nil + delete sign_out_path end should "allow resending ownership confirmation" do @@ -77,6 +89,7 @@ class RackAttackTest < ActionDispatch::IntegrationTest get "/gems/#{@rubygem.name}/owners/resend_confirmation", headers: { REMOTE_ADDR: @ip_address } follow_redirect! + assert_response :success end end @@ -85,14 +98,14 @@ class RackAttackTest < ActionDispatch::IntegrationTest setup do @rubygem = create(:rubygem, name: "test", number: "0.0.1") create(:ownership, user: @user, rubygem: @rubygem) - create(:api_key, key: "12334", push_rubygem: true, user: @user) + create(:api_key, key: "12334", scopes: %i[push_rubygem], owner: @user) end should "allow gem push by ip" do stay_under_push_limit_for("api/push/ip") post "/api/v1/gems", - params: gem_file("test-1.0.0.gem").read, + params: gem_file("test-1.0.0.gem", &:read), headers: { REMOTE_ADDR: @ip_address, HTTP_AUTHORIZATION: "12334", CONTENT_TYPE: "application/octet-stream" } assert_response :success @@ -107,7 +120,7 @@ class RackAttackTest < ActionDispatch::IntegrationTest end should "return 401 for unauthorized request" do - post "/session", params: { session: { password: @user.password } } + post "/session", params: { session: { who: "no@example.com", password: @user.password } } assert_response :unauthorized end @@ -120,11 +133,14 @@ class RackAttackTest < ActionDispatch::IntegrationTest under_backoff_limit = (Rack::Attack::EXP_BASE_REQUEST_LIMIT * level) - 1 @push_exp_throttle_level_key = "#{Rack::Attack::PUSH_EXP_THROTTLE_KEY}/#{level}:#{@ip_address}" under_backoff_limit.times { Rack::Attack.cache.count(@push_exp_throttle_level_key, exp_base_limit_period**level) } + + @push_throttle_per_user_key = "#{Rack::Attack::PUSH_THROTTLE_PER_USER_KEY}/#{level}:#{@user.to_gid}" + under_backoff_limit.times { Rack::Attack.cache.count(@push_throttle_per_user_key, exp_base_limit_period**level) } end - create(:api_key, key: "12334", push_rubygem: true, user: @user) + create(:api_key, key: "12334", scopes: %i[push_rubygem], owner: @user) post "/api/v1/gems", - params: gem_file("test-0.0.0.gem").read, + params: gem_file("test-0.0.0.gem", &:read), headers: { REMOTE_ADDR: @ip_address, HTTP_AUTHORIZATION: "12334", CONTENT_TYPE: "application/octet-stream" } end @@ -137,12 +153,14 @@ class RackAttackTest < ActionDispatch::IntegrationTest assert_nil Rack::Attack.cache.read("#{time_counter}:#{@push_exp_throttle_level_key}") assert_nil Rack::Attack.cache.read("#{prev_time_counter}:#{@push_exp_throttle_level_key}") + assert_nil Rack::Attack.cache.read("#{time_counter}:#{@push_throttle_per_user_key}") + assert_nil Rack::Attack.cache.read("#{prev_time_counter}:#{@push_throttle_per_user_key}") end end should "not rate limit successive requests" do post "/api/v1/gems", - params: gem_file("test-1.0.0.gem").read, + params: gem_file("test-1.0.0.gem", &:read), headers: { REMOTE_ADDR: @ip_address, HTTP_AUTHORIZATION: "12334", CONTENT_TYPE: "application/octet-stream" } assert_response :ok @@ -151,15 +169,15 @@ class RackAttackTest < ActionDispatch::IntegrationTest context "ui requests" do setup do - @user.enable_mfa!(ROTP::Base32.random_base32, :ui_only) + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) stay_under_exponential_limit("clearance/ip") end should "allow for mfa sign in" do post "/session", params: { session: { who: @user.handle, password: @user.password } } # sets session[:mfa_user] - post "/session/mfa_create", - params: { otp: ROTP::TOTP.new(@user.mfa_seed).now }, + post "/session/otp_create", + params: { otp: ROTP::TOTP.new(@user.totp_seed).now }, headers: { REMOTE_ADDR: @ip_address } assert_redirected_to "/dashboard" @@ -167,8 +185,18 @@ class RackAttackTest < ActionDispatch::IntegrationTest should "allow mfa forgot password" do @user.forgot_password! - post "/users/#{@user.id}/password/mfa_edit", - params: { token: @user.confirmation_token, otp: ROTP::TOTP.new(@user.mfa_seed).now }, + get "/password/edit", + params: { token: @user.confirmation_token, user_id: @user.id } + post "/password/otp_edit", + params: { token: @user.confirmation_token, otp: ROTP::TOTP.new(@user.totp_seed).now }, + headers: { REMOTE_ADDR: @ip_address } + + assert_response :ok + end + + should "allow reverse_dependencies index" do + rubygem = create(:rubygem, name: "test", number: "0.0.1") + get "/gems/#{rubygem.name}/reverse_dependencies", headers: { REMOTE_ADDR: @ip_address } assert_response :ok @@ -177,18 +205,18 @@ class RackAttackTest < ActionDispatch::IntegrationTest context "api requests" do setup do - @user.enable_mfa!(ROTP::Base32.random_base32, :ui_and_api) + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) stay_under_exponential_limit("api/ip") - create(:api_key, key: "12334", add_owner: true, yank_rubygem: true, remove_owner: true, user: @user) + create(:api_key, key: "12334", scopes: %i[add_owner yank_rubygem remove_owner], owner: @user) @rubygem = create(:rubygem, name: "test", number: "0.0.1") create(:ownership, user: @user, rubygem: @rubygem) end should "allow gem yank by ip" do delete "/api/v1/gems/yank", - params: { gem_name: @rubygem.to_param, version: @rubygem.latest_version.number }, - headers: { REMOTE_ADDR: @ip_address, HTTP_AUTHORIZATION: "12334", HTTP_OTP: ROTP::TOTP.new(@user.mfa_seed).now } + params: { gem_name: @rubygem.slug, version: @rubygem.latest_version.number }, + headers: { REMOTE_ADDR: @ip_address, HTTP_AUTHORIZATION: "12334", HTTP_OTP: ROTP::TOTP.new(@user.totp_seed).now } assert_response :success end @@ -197,8 +225,8 @@ class RackAttackTest < ActionDispatch::IntegrationTest second_user = create(:user) post "/api/v1/gems/#{@rubygem.name}/owners", - params: { rubygem_id: @rubygem.to_param, email: second_user.email }, - headers: { REMOTE_ADDR: @ip_address, HTTP_AUTHORIZATION: "12334", HTTP_OTP: ROTP::TOTP.new(@user.mfa_seed).now } + params: { rubygem_id: @rubygem.slug, email: second_user.email }, + headers: { REMOTE_ADDR: @ip_address, HTTP_AUTHORIZATION: "12334", HTTP_OTP: ROTP::TOTP.new(@user.totp_seed).now } assert_response :success end @@ -208,13 +236,28 @@ class RackAttackTest < ActionDispatch::IntegrationTest create(:ownership, user: second_user, rubygem: @rubygem) delete "/api/v1/gems/#{@rubygem.name}/owners", - params: { rubygem_id: @rubygem.to_param, email: second_user.email }, - headers: { REMOTE_ADDR: @ip_address, HTTP_AUTHORIZATION: "12334", HTTP_OTP: ROTP::TOTP.new(@user.mfa_seed).now } + params: { rubygem_id: @rubygem.slug, email: second_user.email }, + headers: { REMOTE_ADDR: @ip_address, HTTP_AUTHORIZATION: "12334", HTTP_OTP: ROTP::TOTP.new(@user.totp_seed).now } assert_response :success end end end + + context "ownership requests" do + setup do + sign_in_as(@user) + @rubygem = create(:rubygem, name: "test", downloads: 2_000) + create(:version, rubygem: @rubygem, created_at: 2.years.ago) + stay_under_ownership_request_limit_for("ownership_requests/email") + post "/gems/#{@rubygem.name}/ownership_requests", params: { rubygem_id: @rubygem.name, note: "small note" } + end + + should "allow creating new requests" do + assert_redirected_to "/gems/test/adoptions" + assert_equal "small note", @rubygem.ownership_requests.last.note + end + end end context "requests is higher than limit" do @@ -242,7 +285,7 @@ class RackAttackTest < ActionDispatch::IntegrationTest should "throttle forgot password" do exceed_limit_for("clearance/ip") - post "/passwords", + post "/password", params: { password: { email: @user.email } }, headers: { REMOTE_ADDR: @ip_address } @@ -260,7 +303,7 @@ class RackAttackTest < ActionDispatch::IntegrationTest end should "throttle profile update" do - cookies[:remember_token] = @user.remember_token + post session_path(session: { who: @user.handle, password: PasswordHelpers::SECURE_TEST_PASSWORD }) exceed_limit_for("clearance/ip") patch "/profile", @@ -269,8 +312,16 @@ class RackAttackTest < ActionDispatch::IntegrationTest assert_response :too_many_requests end + should "throttle profile update per user" do + sign_in_as @user + update_limit_for("password/user:#{@user.email}", exceeding_limit) + patch "/profile" + + assert_response :too_many_requests + end + should "throttle profile delete" do - cookies[:remember_token] = @user.remember_token + post session_path(session: { who: @user.handle, password: PasswordHelpers::SECURE_TEST_PASSWORD }) exceed_limit_for("clearance/ip") delete "/profile", @@ -279,15 +330,32 @@ class RackAttackTest < ActionDispatch::IntegrationTest assert_response :too_many_requests end + should "throttle profile delete per user" do + sign_in_as @user + update_limit_for("password/user:#{@user.email}", exceeding_limit) + delete "/profile" + + assert_response :too_many_requests + end + + should "throttle reverse_dependencies index" do + exceed_limit_for("clearance/ip") + rubygem = create(:rubygem, name: "test", number: "0.0.1") + get "/gems/#{rubygem.name}/reverse_dependencies", + headers: { REMOTE_ADDR: @ip_address } + + assert_response :too_many_requests + end + context "owners requests" do setup do - cookies[:remember_token] = @user.remember_token + post session_path(session: { who: @user.handle, password: PasswordHelpers::SECURE_TEST_PASSWORD }) @rubygem = create(:rubygem) create(:ownership, :unconfirmed, rubygem: @rubygem, user: @user) end teardown do - cookies[:remember_token] = nil + delete sign_out_path end should "throttle ownership confirmation resend" do @@ -321,6 +389,7 @@ class RackAttackTest < ActionDispatch::IntegrationTest post "/email_confirmations", params: { email_confirmation: { email: @user.email } }, headers: { REMOTE_ADDR: @ip_address } + assert_response :too_many_requests end @@ -328,6 +397,7 @@ class RackAttackTest < ActionDispatch::IntegrationTest exceed_email_limit_for("email_confirmations/email") post "/email_confirmations", params: { email_confirmation: { email: @user.email } } + assert_response :too_many_requests end end @@ -336,7 +406,7 @@ class RackAttackTest < ActionDispatch::IntegrationTest should "throttle by ip" do exceed_limit_for("clearance/ip") - post "/passwords", + post "/password", params: { password: { email: @user.email } }, headers: { REMOTE_ADDR: @ip_address } @@ -346,7 +416,8 @@ class RackAttackTest < ActionDispatch::IntegrationTest should "throttle by email" do exceed_email_limit_for("password/email") - post "/passwords", params: { password: { email: @user.email } } + post "/password", params: { password: { email: @user.email } } + assert_response :too_many_requests end end @@ -359,10 +430,10 @@ class RackAttackTest < ActionDispatch::IntegrationTest should "throttle gem push by ip" do exceed_push_limit_for("api/push/ip") - create(:api_key, key: "12334", push_rubygem: true, user: @user) + create(:api_key, key: "12334", scopes: %i[push_rubygem], owner: @user) post "/api/v1/gems", - params: gem_file("test-1.0.0.gem").read, + params: gem_file("test-1.0.0.gem", &:read), headers: { REMOTE_ADDR: @ip_address, HTTP_AUTHORIZATION: "12334", CONTENT_TYPE: "application/octet-stream" } assert_response :too_many_requests @@ -370,13 +441,29 @@ class RackAttackTest < ActionDispatch::IntegrationTest end context "exponential backoff" do - setup { @mfa_max_period = { 1 => 300, 2 => 90_000 } } + setup do + @mfa_max_period = { 1 => 300, 2 => 90_000 } + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + @api_key = "12345" + create(:api_key, key: @api_key, owner: @user) + end Rack::Attack::EXP_BACKOFF_LEVELS.each do |level| should "throttle for mfa sign in at level #{level}" do freeze_time do exceed_exponential_limit_for("clearance/ip/#{level}", level) - post "/session/mfa_create", headers: { REMOTE_ADDR: @ip_address } + post "/session/otp_create", headers: { REMOTE_ADDR: @ip_address } + + assert_throttle_at(level) + end + end + + should "throttle for mfa sign in per user at level #{level}" do + freeze_time do + # sign page sets mfa_user in session + post session_path(session: { who: @user.handle, password: PasswordHelpers::SECURE_TEST_PASSWORD }) + exceed_exponential_user_limit_for("clearance/user/#{level}", @user.id, level) + post "/session/otp_create" assert_throttle_at(level) end @@ -387,17 +474,46 @@ class RackAttackTest < ActionDispatch::IntegrationTest exceed_exponential_limit_for("#{Rack::Attack::PUSH_EXP_THROTTLE_KEY}/#{level}", level) post "/api/v1/gems", - params: gem_file("test-0.0.0.gem").read, + params: gem_file("test-0.0.0.gem", &:read), headers: { REMOTE_ADDR: @ip_address, HTTP_AUTHORIZATION: @user.api_key, CONTENT_TYPE: "application/octet-stream" } assert_throttle_at(level) end end - should "throttle mfa create at level #{level}" do + should "throttle totp create at level #{level}" do freeze_time do exceed_exponential_limit_for("clearance/ip/#{level}", level) - post "/multifactor_auth", headers: { REMOTE_ADDR: @ip_address } + post "/totp", headers: { REMOTE_ADDR: @ip_address } + + assert_throttle_at(level) + end + end + + should "throttle totp create per user at level #{level}" do + freeze_time do + sign_in_as(@user) + exceed_exponential_user_limit_for("clearance/user/#{level}", @user.email, level) + post "/totp" + + assert_throttle_at(level) + end + end + + should "throttle totp destroy at level #{level}" do + freeze_time do + exceed_exponential_limit_for("clearance/ip/#{level}", level) + post "/totp", headers: { REMOTE_ADDR: @ip_address } + + assert_throttle_at(level) + end + end + + should "throttle totp destroy per user at level #{level}" do + freeze_time do + sign_in_as(@user) + exceed_exponential_user_limit_for("clearance/user/#{level}", @user.email, level) + post "/totp" assert_throttle_at(level) end @@ -412,6 +528,16 @@ class RackAttackTest < ActionDispatch::IntegrationTest end end + should "throttle mfa update per user at level #{level}" do + freeze_time do + sign_in_as(@user) + exceed_exponential_user_limit_for("clearance/user/#{level}", @user.email, level) + put "/multifactor_auth" + + assert_throttle_at(level) + end + end + should "throttle api key show at level #{level}" do freeze_time do exceed_exponential_limit_for("api/ip/#{level}", level) @@ -421,10 +547,49 @@ class RackAttackTest < ActionDispatch::IntegrationTest end end + should "throttle api key show by api key #{level}" do + freeze_time do + exceed_exponential_api_key_limit_for("api/key/#{level}", @user.to_gid, level) + get "/api/v1/api_key.json", headers: { HTTP_AUTHORIZATION: @api_key } + + assert_throttle_at(level) + end + end + + should "throttle api key create at level #{level}" do + freeze_time do + exceed_exponential_limit_for("api/ip/#{level}", level) + get "/api/v1/api_key.json", headers: { REMOTE_ADDR: @ip_address } + + assert_throttle_at(level) + end + end + + should "throttle api key create by api key #{level}" do + freeze_time do + exceed_exponential_api_key_limit_for("api/key/#{level}", @user.to_gid, level) + post "/api/v1/api_key.json", headers: { HTTP_AUTHORIZATION: @api_key } + + assert_throttle_at(level) + end + end + should "throttle mfa forgot password at level #{level}" do freeze_time do exceed_exponential_limit_for("clearance/ip/#{level}", level) - post "/users/#{@user.id}/password/mfa_edit", headers: { REMOTE_ADDR: @ip_address } + post "/password/otp_edit", headers: { REMOTE_ADDR: @ip_address } + + assert_throttle_at(level) + end + end + + should "throttle for mfa forgot password per user at level #{level}" do + freeze_time do + @user.forgot_password! + exceed_exponential_user_limit_for("clearance/user/#{level}", @user.confirmation_token, level) + + post "/password/otp_edit", + params: { token: @user.confirmation_token, otp: ROTP::TOTP.new(@user.totp_seed).now } assert_throttle_at(level) end @@ -439,6 +604,15 @@ class RackAttackTest < ActionDispatch::IntegrationTest end end + should "throttle gem yank by api key #{level}" do + freeze_time do + exceed_exponential_api_key_limit_for("api/key/#{level}", @user.to_gid, level) + delete "/api/v1/gems/yank", headers: { HTTP_AUTHORIZATION: @api_key } + + assert_throttle_at(level) + end + end + should "throttle owner add by ip #{level}" do freeze_time do exceed_exponential_limit_for("api/ip/#{level}", level) @@ -448,6 +622,15 @@ class RackAttackTest < ActionDispatch::IntegrationTest end end + should "throttle owner add by api key #{level}" do + freeze_time do + exceed_exponential_api_key_limit_for("api/key/#{level}", @user.to_gid, level) + post "/api/v1/gems/somegem/owners", headers: { HTTP_AUTHORIZATION: @api_key } + + assert_throttle_at(level) + end + end + should "throttle owner remove by ip #{level}" do freeze_time do exceed_exponential_limit_for("api/ip/#{level}", level) @@ -456,6 +639,15 @@ class RackAttackTest < ActionDispatch::IntegrationTest assert_throttle_at(level) end end + + should "throttle owner remove by api key #{level}" do + freeze_time do + exceed_exponential_api_key_limit_for("api/key/#{level}", @user.to_gid, level) + delete "/api/v1/gems/somegem/owners", headers: { HTTP_AUTHORIZATION: @api_key } + + assert_throttle_at(level) + end + end end end @@ -464,14 +656,14 @@ class RackAttackTest < ActionDispatch::IntegrationTest setup { update_limit_for("password/email:#{@user.email}", exceeding_limit) } should "throttle for sign in ignoring case" do - post "/passwords", + post "/password", params: { password: { email: "Nick@example.com" } } assert_response :too_many_requests end should "throttle for sign in ignoring spaces" do - post "/passwords", + post "/password", params: { password: { email: "n ick@example.com" } } assert_response :too_many_requests @@ -485,7 +677,7 @@ class RackAttackTest < ActionDispatch::IntegrationTest end teardown do - cookies[:remember_token] = nil + delete sign_out_path end should "throttle resending ownership confirmation" do @@ -516,10 +708,32 @@ class RackAttackTest < ActionDispatch::IntegrationTest end end end + + context "ownership requests" do + setup do + sign_in_as(@user) + @rubygem = create(:rubygem, name: "test", downloads: 2_000) + create(:version, rubygem: @rubygem, created_at: 2.years.ago) + exceed_ownership_request_limit_for("ownership_requests/email") + post "/gems/#{@rubygem.name}/ownership_requests", params: { rubygem_id: @rubygem.name, note: "small note" } + end + + should "throttle creating new requests" do + assert_response :too_many_requests + assert_empty @rubygem.ownership_requests + end + end + end + + private + + def sign_in_as(user) + post session_path(session: { who: user.handle, password: PasswordHelpers::SECURE_TEST_PASSWORD }) + post "/session/otp_create", params: { otp: ROTP::TOTP.new(@user.totp_seed).now } if user.mfa_enabled? end def set_owners_session(_rubygem, user) - cookies[:remember_token] = user.remember_token + sign_in_as(user) post authenticate_session_path(verify_password: { password: PasswordHelpers::SECURE_TEST_PASSWORD }) end end diff --git a/test/integration/routing_test.rb b/test/integration/routing_test.rb index 3ffd8ccee0a..173e9a28fc8 100644 --- a/test/integration/routing_test.rb +++ b/test/integration/routing_test.rb @@ -2,14 +2,14 @@ class RoutingTest < ActionDispatch::IntegrationTest def contoller_in_ui?(controller) - !controller.nil? && controller !~ /^api|internal|sendgrid_events.*$/ + !controller.nil? && controller !~ %r{^api|internal|sendgrid_events.*|view_components(_system_test)?|turbo|admin/admin|avatars$} end setup do @prev_env = ENV["RAILS_ENV"] ENV["RAILS_ENV"] = "production" routes = Rails.application.routes.routes - @ui_paths_verb = routes.map { |r| [r.path.spec.to_s, r.verb] if contoller_in_ui? r.defaults[:controller] }.compact.to_h + @ui_paths_verb = routes.filter_map { |r| [r.path.spec.to_s, r.verb] if contoller_in_ui? r.defaults[:controller] }.to_h end test "active storate routes don't exist" do @@ -22,7 +22,7 @@ def contoller_in_ui?(controller) @ui_paths_verb.each do |path, verb| next if path == "/" # adding random format after root (/) gives 404 - assert_raises(ActionController::RoutingError) do + assert_raises(ActionController::RoutingError, "#{verb} #{path} should raise") do # ex: get(/password/new.json) send(verb.downcase, path.gsub("(.:format)", ".something")) end @@ -54,14 +54,14 @@ def contoller_in_ui?(controller) test "long static page route doesn't raise Errno::ENAMETOOLONG" do assert_raises(ActionController::RoutingError) do - get "/pages/%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF"\ - "%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF"\ - "%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD"\ - "%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF"\ - "%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF"\ - "%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD"\ - "%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF"\ - "%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD/etc/passwd" + get "/pages/%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF" \ + "%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF" \ + "%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD" \ + "%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF" \ + "%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF" \ + "%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD" \ + "%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF" \ + "%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD/etc/passwd" end end diff --git a/test/integration/rubygems_test.rb b/test/integration/rubygems_test.rb new file mode 100644 index 00000000000..f6d1095e31a --- /dev/null +++ b/test/integration/rubygems_test.rb @@ -0,0 +1,20 @@ +require "test_helper" + +class RubygemsTest < ActionDispatch::IntegrationTest + setup do + create_list(:rubygem, 20) # rubocop:disable FactoryBot/ExcessiveCreateList + create(:rubygem, name: "arrakis", number: "1.0.0") + end + + test "gems list shows pagination" do + get "/gems" + + assert page.has_content? "arrakis" + end + + test "gems list doesn't fall pray to path_params query param" do + get "/gems?path_params=string" + + assert page.has_content? "arrakis" + end +end diff --git a/test/integration/search_test.rb b/test/integration/search_test.rb index a04bd064bc0..ad5c2c91b6f 100644 --- a/test/integration/search_test.rb +++ b/test/integration/search_test.rb @@ -1,7 +1,7 @@ require "test_helper" class SearchTest < SystemTest - include ESHelper + include SearchKickHelper test "searching for a gem" do create(:rubygem, name: "LDAP", number: "1.0.0") @@ -32,6 +32,7 @@ class SearchTest < SystemTest assert page.has_content? "Yanked (1)" click_link "Yanked (1)" + assert page.has_content? "LDAP" assert page.has_selector? "a[href='#{rubygem_path('LDAP')}']" end @@ -64,6 +65,7 @@ class SearchTest < SystemTest import_and_refresh visit "/search?query=ruby&original_script_name=javascript:alert(1)//&script_name=javascript:alert(1)//" + assert page.has_content? "ruby-ruby" assert page.has_link?("Next", href: "/search?page=2&query=ruby") Kaminari.configure { |c| c.default_per_page = 30 } @@ -81,13 +83,36 @@ class SearchTest < SystemTest import_and_refresh visit "/search?query=ruby" + assert page.has_content? "Displaying gem 1 - 1 of 3 in total" click_link "Last" + assert page.has_content? "Displaying gem 2 - 2 of 3 in total" Gemcutter::SEARCH_MAX_PAGES = orignal_val Kaminari.configure { |c| c.default_per_page = 30 } end end + + test "searching for reverse dependencies" do + dependency = create(:rubygem) + create(:version, rubygem: dependency) + + gem = create(:rubygem) + version_one = create(:version, rubygem: gem) + create(:dependency, :runtime, version: version_one, rubygem: dependency) + + visit "/gems/#{dependency.name}/reverse_dependencies" + + assert page.has_content? "Search reverse dependencies Gems…" + within ".reverse__dependencies" do + assert page.has_content? gem.name + end + + visit "/gems/#{gem.name}/reverse_dependencies" + + refute page.has_content? "Search reverse dependencies Gems…" + assert page.has_content? "This gem has no reverse dependencies" + end end diff --git a/test/integration/sendgrid_webhook_test.rb b/test/integration/sendgrid_webhook_test.rb index 634bbee97d9..f1790a22f03 100644 --- a/test/integration/sendgrid_webhook_test.rb +++ b/test/integration/sendgrid_webhook_test.rb @@ -1,6 +1,8 @@ require "test_helper" class SendgridWebhookTest < ActionDispatch::IntegrationTest + include ActiveJob::TestHelper + test "responds 200 OK to valid credentials" do params = [ { sg_event_id: "nwlyJ3Ej3wUBZQiaCAL5YA==", timestamp: Time.current.to_i } @@ -28,6 +30,7 @@ class SendgridWebhookTest < ActionDispatch::IntegrationTest error = assert_raises(RuntimeError) do post "/sendgrid_events", params: params, as: :json, headers: authorization_header end + assert_equal("Invalid authentication options", error.message) end @@ -36,21 +39,22 @@ class SendgridWebhookTest < ActionDispatch::IntegrationTest { email: "user1@example.com", sg_event_id: "nwlyJ3Ej3wUBZQiaCAL5YA==", timestamp: Time.current.to_i, event: "bounce" }, { email: "user2@example.com", sg_event_id: "t61hI0Xpmk8XSR1YX4s0Kg==", timestamp: Time.current.to_i, event: "delivered" } ] - post "/sendgrid_events", params: params, as: :json, headers: authorization_header + assert_enqueued_jobs 2, only: ProcessSendgridEventJob do + post "/sendgrid_events", params: params, as: :json, headers: authorization_header + end assert_response :ok events = SendgridEvent.all + assert_equal 2, events.size assert_equal "user1@example.com", events.first.email assert_equal "user2@example.com", events.last.email assert events.all?(&:pending?) - - assert_equal 2, Delayed::Job.count end - def authorization_header(password: Rails.application.secrets.sendgrid_webhook_password) - username = Rails.application.secrets.sendgrid_webhook_username + def authorization_header(password: "password") + username = "test_sendgrid_webhook_user" encoded_credentials = Base64.encode64("#{username}:#{password}") { "Authorization" => "Basic #{encoded_credentials}" } end diff --git a/test/integration/session_test.rb b/test/integration/session_test.rb index c29a309ec32..e9cf8f161ff 100644 --- a/test/integration/session_test.rb +++ b/test/integration/session_test.rb @@ -3,6 +3,8 @@ class SessionTest < ActionDispatch::IntegrationTest def retrive_authenticity_token(path) get path + + assert_response :success request.session[:_csrf_token] end @@ -10,7 +12,7 @@ def retrive_authenticity_token(path) create(:user, handle: "johndoe", password: PasswordHelpers::SECURE_TEST_PASSWORD) @last_session_token = retrive_authenticity_token sign_in_path post session_path(session: { who: "johndoe", password: PasswordHelpers::SECURE_TEST_PASSWORD }) - ActionController::Base.allow_forgery_protection = true # default is false + ActionController::Base.allow_forgery_protection = true # default is false in test env end teardown do @@ -31,13 +33,20 @@ def retrive_authenticity_token(path) @last_session_token = retrive_authenticity_token edit_profile_path delete sign_out_path(authenticity_token: request.session[:_csrf_token]) + assert_response :redirect + assert_redirected_to sign_in_path + create(:user, handle: "bob", password: PasswordHelpers::SECURE_TEST_PASSWORD) post session_path( session: { who: "bob", password: PasswordHelpers::SECURE_TEST_PASSWORD }, authenticity_token: request.session[:_csrf_token] ) + assert_response :redirect + assert_redirected_to dashboard_path + patch "/profile", params: { user: { handle: "alice" }, authenticity_token: @last_session_token } + assert_response :forbidden refute_equal request.session[:_csrf_token], @last_session_token end diff --git a/test/integration/settings_test.rb b/test/integration/settings_test.rb deleted file mode 100644 index d7fd62190d0..00000000000 --- a/test/integration/settings_test.rb +++ /dev/null @@ -1,105 +0,0 @@ -require "test_helper" - -class SettingsTest < SystemTest - setup do - @user = create(:user, email: "nick@example.com", password: PasswordHelpers::SECURE_TEST_PASSWORD, handle: "nick1", mail_fails: 1) - - page.driver.browser.set_cookie("mfa_feature=true") - end - - def sign_in - visit sign_in_path - fill_in "Email or Username", with: @user.reload.email - fill_in "Password", with: @user.password - click_button "Sign in" - end - - def enable_mfa - key = ROTP::Base32.random_base32 - @user.enable_mfa!(key, :ui_only) - end - - def change_auth_level(type) - page.select type - find("#mfa-edit input[type=submit]").click - end - - def mfa_key - key_regex = /( (\w{4})){8}/ - page.find_by_id("mfa-key").text.match(key_regex)[0].delete("\s") - end - - test "enabling multifactor authentication with valid otp" do - sign_in - visit edit_settings_path - click_button "Register a new device" - - assert page.has_content? "Enabling multifactor auth" - - totp = ROTP::TOTP.new(mfa_key) - page.fill_in "otp", with: totp.now - click_button "Enable" - - assert page.has_content? "Recovery codes" - - click_link "Continue" - - assert page.has_content? "You have enabled multifactor authentication." - end - - test "enabling multifactor authentication with invalid otp" do - sign_in - visit edit_settings_path - click_button "Register a new device" - - assert page.has_content? "Enabling multifactor auth" - - totp = ROTP::TOTP.new(ROTP::Base32.random_base32) - page.fill_in "otp", with: totp.now - click_button "Enable" - - assert page.has_content? "You have not yet enabled multifactor authentication." - end - - test "disabling multifactor authentication with valid otp" do - sign_in - enable_mfa - visit edit_settings_path - - page.fill_in "otp", with: ROTP::TOTP.new(@user.mfa_seed).now - change_auth_level "Disabled" - - assert page.has_content? "You have not yet enabled multifactor authentication." - end - - test "disabling multifactor authentication with invalid otp" do - sign_in - enable_mfa - visit edit_settings_path - - key = ROTP::Base32.random_base32 - page.fill_in "otp", with: ROTP::TOTP.new(key).now - change_auth_level "Disabled" - - assert page.has_content? "You have enabled multifactor authentication." - end - - test "disabling multifactor authentication with recovery code" do - sign_in - visit edit_settings_path - click_button "Register a new device" - - totp = ROTP::TOTP.new(mfa_key) - page.fill_in "otp", with: totp.now - click_button "Enable" - - assert page.has_content? "Recovery codes" - - recoveries = page.find_by_id("recovery-code-list").text.split - click_link "Continue" - page.fill_in "otp", with: recoveries.sample - change_auth_level "Disabled" - - assert page.has_content? "You have not yet enabled multifactor authentication." - end -end diff --git a/test/integration/sign_in_test.rb b/test/integration/sign_in_test.rb index ffbcf3b53e8..da222320e5d 100644 --- a/test/integration/sign_in_test.rb +++ b/test/integration/sign_in_test.rb @@ -2,9 +2,9 @@ class SignInTest < SystemTest setup do - create(:user, email: "nick@example.com", password: PasswordHelpers::SECURE_TEST_PASSWORD, handle: nil) + @user = create(:user, email: "nick@example.com", password: PasswordHelpers::SECURE_TEST_PASSWORD, handle: nil) @mfa_user = create(:user, email: "john@example.com", password: PasswordHelpers::SECURE_TEST_PASSWORD, - mfa_level: :ui_only, mfa_seed: "thisisonemfaseed", + mfa_level: :ui_only, totp_seed: "thisisonetotpseed", mfa_recovery_codes: %w[0123456789ab ba9876543210]) end @@ -15,6 +15,10 @@ class SignInTest < SystemTest click_button "Sign in" assert page.has_content? "Sign out" + assert page.has_content? "We now support security devices!" + + assert_event Events::UserEvent::LOGIN_SUCCESS, { authentication_method: "password" }, + User.find_by(email: "nick@example.com").events.where(tag: Events::UserEvent::LOGIN_SUCCESS).sole end test "signing in with uppercase email" do @@ -69,25 +73,48 @@ class SignInTest < SystemTest fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD click_button "Sign in" - assert page.has_content? "Multifactor authentication" + StatsD.expects(:distribution) - fill_in "OTP code", with: ROTP::TOTP.new("thisisonemfaseed").now - click_button "Sign in" + assert page.has_content? "Multi-factor authentication" + fill_in "OTP or recovery code", with: ROTP::TOTP.new("thisisonetotpseed").now + click_button "Authenticate" assert page.has_content? "Sign out" + assert page.has_content? "We now support security devices!" + + assert_event Events::UserEvent::LOGIN_SUCCESS, { authentication_method: "password", two_factor_method: "otp", two_factor_label: "OTP" }, + User.find_by(email: "john@example.com").events.where(tag: Events::UserEvent::LOGIN_SUCCESS).sole end - test "signing in with invalid otp when mfa enabled" do + test "signing in with current valid otp when mfa enabled but 30 minutes has passed" do visit sign_in_path fill_in "Email or Username", with: "john@example.com" fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD click_button "Sign in" - assert page.has_content? "Multifactor authentication" + assert page.has_content? "Multi-factor authentication" + + travel 30.minutes do + fill_in "OTP or recovery code", with: ROTP::TOTP.new("thisisonetotpseed").now + click_button "Authenticate" + + assert page.has_content? "Sign in" + expected_notice = "Your login page session has expired." + + assert page.has_selector? "#flash_notice", text: expected_notice + end + end - fill_in "OTP code", with: "11111" + test "signing in with invalid otp when mfa enabled" do + visit sign_in_path + fill_in "Email or Username", with: "john@example.com" + fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD click_button "Sign in" + assert page.has_content? "Multi-factor authentication" + fill_in "OTP or recovery code", with: "11111" + click_button "Authenticate" + assert page.has_content? "Sign in" end @@ -97,41 +124,131 @@ class SignInTest < SystemTest fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD click_button "Sign in" - assert page.has_content? "Multifactor authentication" + assert page.has_content? "Multi-factor authentication" + fill_in "OTP or recovery code", with: "0123456789ab" + click_button "Authenticate" + + assert page.has_content? "Sign out" + end - fill_in "OTP code", with: "0123456789ab" + test "signing in with invalid recovery code when mfa enabled" do + visit sign_in_path + fill_in "Email or Username", with: "john@example.com" + fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD click_button "Sign in" + assert page.has_content? "Multi-factor authentication" + fill_in "OTP or recovery code", with: "ab0123456789" + click_button "Authenticate" + + assert page.has_content? "Sign in" + end + + test "signing in with mfa disabled with gem ownership that exceeds the recommended download threshold" do + rubygem = create(:rubygem) + create(:ownership, user: @user, rubygem: rubygem) + GemDownload.increment( + Rubygem::MFA_RECOMMENDED_THRESHOLD + 1, + rubygem_id: rubygem.id + ) + + visit sign_in_path + fill_in "Email or Username", with: "nick@example.com" + fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD + click_button "Sign in" + + expected_notice = "For protection of your account and your gems, we encourage you to set up multi-factor authentication. " \ + "Your account will be required to have MFA enabled in the future." + + assert page.has_selector? "#flash_notice", text: expected_notice + assert_current_path(new_totp_path) assert page.has_content? "Sign out" end - test "signing in with invalid recovery code when mfa enabled" do + test "signing in with mfa enabled on `ui_only` with gem ownership that exceeds the recommended download threshold" do + rubygem = create(:rubygem) + create(:ownership, user: @mfa_user, rubygem: rubygem) + GemDownload.increment( + Rubygem::MFA_RECOMMENDED_THRESHOLD + 1, + rubygem_id: rubygem.id + ) + visit sign_in_path fill_in "Email or Username", with: "john@example.com" fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD click_button "Sign in" - assert page.has_content? "Multifactor authentication" + assert page.has_content? "Multi-factor authentication" + fill_in "OTP or recovery code", with: "0123456789ab" + click_button "Authenticate" - fill_in "OTP code", with: "ab0123456789" + expected_notice = "For protection of your account and your gems, we encourage you to change your MFA level " \ + "to \"UI and gem signin\" or \"UI and API\". Your account will be required to have MFA enabled " \ + "on one of these levels in the future." + + assert page.has_selector? "#flash_notice", text: expected_notice + assert_current_path(edit_settings_path) + assert page.has_content? "Sign out" + end + + test "signing in with mfa enabled on `ui_and_gem_signin` with gem ownership that exceeds the recommended download threshold" do + @mfa_user.update!(mfa_level: :ui_and_gem_signin) + rubygem = create(:rubygem) + create(:ownership, user: @mfa_user, rubygem: rubygem) + GemDownload.increment( + Rubygem::MFA_RECOMMENDED_THRESHOLD + 1, + rubygem_id: rubygem.id + ) + + visit sign_in_path + fill_in "Email or Username", with: "john@example.com" + fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD click_button "Sign in" - assert page.has_content? "Sign in" + assert page.has_content? "Multi-factor authentication" + fill_in "OTP or recovery code", with: "0123456789ab" + click_button "Authenticate" + + assert_current_path(dashboard_path) + refute page.has_selector? "#flash_notice" + assert page.has_content? "Sign out" end - test "siging in when user does not have handle" do - @mfa_user.update_attribute(:handle, nil) + test "signing in with mfa enabled on `ui_and_api` with gem ownership that exceeds the recommended download threshold" do + @mfa_user.update!(mfa_level: :ui_and_api) + rubygem = create(:rubygem) + create(:ownership, user: @mfa_user, rubygem: rubygem) + GemDownload.increment( + Rubygem::MFA_RECOMMENDED_THRESHOLD + 1, + rubygem_id: rubygem.id + ) visit sign_in_path fill_in "Email or Username", with: "john@example.com" fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD click_button "Sign in" - assert page.has_content? "Multifactor authentication" + assert page.has_content? "Multi-factor authentication" + fill_in "OTP or recovery code", with: "0123456789ab" + click_button "Authenticate" - fill_in "OTP code", with: ROTP::TOTP.new("thisisonemfaseed").now + assert_current_path(dashboard_path) + refute page.has_selector? "#flash_notice" + assert page.has_content? "Sign out" + end + + test "siging in when user does not have handle" do + @mfa_user.update_attribute(:handle, nil) + + visit sign_in_path + fill_in "Email or Username", with: "john@example.com" + fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD click_button "Sign in" + assert page.has_content? "Multi-factor authentication" + fill_in "OTP or recovery code", with: ROTP::TOTP.new("thisisonetotpseed").now + click_button "Authenticate" + assert page.has_content? "john@example.com" assert page.has_content? "Sign out" end @@ -155,6 +272,7 @@ class SignInTest < SystemTest travel 15.days do visit edit_profile_path + assert page.has_content? "Sign in" end end @@ -168,5 +286,23 @@ class SignInTest < SystemTest click_button "Sign in" assert page.has_content? "Sign in" + assert page.has_content? "Your account was blocked by rubygems team. Please email support@rubygems.org to recover your account." + end + + test "sign in to deleted account" do + User.find_by!(email: "nick@example.com").update!(deleted_at: Time.zone.now) + + visit sign_in_path + fill_in "Email or Username", with: "nick@example.com" + fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD + click_button "Sign in" + + assert page.has_content? "Sign in" + assert page.has_content? "Bad email or password." + end + + teardown do + Capybara.reset_sessions! + Capybara.use_default_driver end end diff --git a/test/integration/sign_up_test.rb b/test/integration/sign_up_test.rb index 5832c56321f..5b1128b5499 100644 --- a/test/integration/sign_up_test.rb +++ b/test/integration/sign_up_test.rb @@ -10,6 +10,21 @@ class SignUpTest < SystemTest click_button "Sign up" assert page.has_selector? "#flash_notice", text: "A confirmation mail has been sent to your email address." + assert_event Events::UserEvent::CREATED, { email: "email@person.com" }, + User.find_by(handle: "nick").events.where(tag: Events::UserEvent::CREATED).sole + end + + test "sign up stores original email casing" do + visit sign_up_path + + fill_in "Email", with: "Email@person.com" + fill_in "Username", with: "nick" + fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD + click_button "Sign up" + + assert page.has_selector? "#flash_notice", text: "A confirmation mail has been sent to your email address." + + assert_equal "Email@person.com", User.last.email end test "sign up with no handle" do @@ -50,6 +65,7 @@ class SignUpTest < SystemTest Rails.application.reload_routes! visit root_path + refute page.has_content? "Sign up" assert_raises(ActionController::RoutingError) do visit "/sign_up" @@ -68,14 +84,24 @@ class SignUpTest < SystemTest fill_in "Email", with: "email@person.com" fill_in "Username", with: "nick" fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD - click_button "Sign up" + + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + click_button "Sign up" + end link = last_email_link + assert_not_nil link visit link - assert page.has_content? "Sign out" + assert page.has_content? "Sign in" assert page.has_selector? "#flash_notice", text: "Your email address has been verified" + + fill_in "Email or Username", with: "email@person.com" + fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD + click_button "Sign in" + + assert page.has_content? "Sign out" end teardown do diff --git a/test/integration/stats_test.rb b/test/integration/stats_test.rb deleted file mode 100644 index 303924fee92..00000000000 --- a/test/integration/stats_test.rb +++ /dev/null @@ -1,17 +0,0 @@ -require "test_helper" -require "capybara/minitest" - -class StatsTest < SystemTest - setup do - headless_chrome_driver - @rubygem = create(:rubygem, number: "0.0.1", downloads: 100) - end - - test "downloads animation bar" do - visit stats_path - assert page.find(:css, ".stats__graph__gem__meter") - assert page.has_content?(@rubygem.downloads) - end - - teardown { Capybara.use_default_driver } -end diff --git a/test/integration/transitive_dependencies_test.rb b/test/integration/transitive_dependencies_test.rb deleted file mode 100644 index ce5c355d8fa..00000000000 --- a/test/integration/transitive_dependencies_test.rb +++ /dev/null @@ -1,52 +0,0 @@ -require "test_helper" -require "capybara/minitest" - -class TransitiveDependenciesTest < SystemTest - setup { headless_chrome_driver } - - test "loading transitive dependencies using ajax" do - @version_one = create(:version) - @rubygem_one = @version_one.rubygem - - @rubygem_two = create(:rubygem) - @version_two = ["1.0.2", "2.4.3", "4.5.6"].map do |ver_number| - FactoryBot.create(:version, number: ver_number, rubygem: @rubygem_two) - end - @version_three = create(:version) - @rubygem_three = @version_three.rubygem - @version_four = create(:version) - @rubygem_four = @version_four.rubygem - - @version_one.dependencies << create(:dependency, - requirements: ">=0", - scope: :runtime, - version: @version_one, - rubygem: @rubygem_two) - @version_two.each do |ver2| - ver2.dependencies << create(:dependency, - version: ver2, - rubygem: @rubygem_three) - - ver2.dependencies << create(:dependency, - version: ver2, - rubygem: @rubygem_four) - end - - visit rubygem_version_dependencies_path(rubygem_id: @rubygem_one.name, version_id: @version_one.number) - assert page.has_content?(@rubygem_one.name) - assert page.has_content?(@version_one.number) - assert page.has_content?(@rubygem_two.name) - assert page.has_content?(@version_two[2].number) - find("span.deps_expanded-link").click - assert page.has_content?(@rubygem_three.name) - assert page.has_content?(@version_three.number) - assert page.has_content?(@rubygem_four.name) - assert page.has_content?(@version_four.number) - end - - # Reset sessions and driver between tests - teardown do - Capybara.reset_sessions! - Capybara.use_default_driver - end -end diff --git a/test/integration/yank_test.rb b/test/integration/yank_test.rb index 683d396af4f..3d433b0da4d 100644 --- a/test/integration/yank_test.rb +++ b/test/integration/yank_test.rb @@ -7,7 +7,7 @@ class YankTest < SystemTest create(:ownership, user: @user, rubygem: @rubygem) @user_api_key = "12345" - create(:api_key, user: @user, key: @user_api_key, yank_rubygem: true) + create(:api_key, owner: @user, key: @user_api_key, scopes: %i[yank_rubygem]) Dir.chdir(Dir.mktmpdir) visit sign_in_path @@ -24,9 +24,11 @@ class YankTest < SystemTest page.driver.delete yank_api_v1_rubygems_path(gem_name: @rubygem.name, version: "2.2.2") visit dashboard_path + assert page.has_content? "sandworm" click_link "sandworm" + assert page.has_content?("1.1.1") refute page.has_content?("2.2.2") @@ -34,38 +36,51 @@ class YankTest < SystemTest click_link "Show all versions (2 total)" end click_link "2.2.2" + assert page.has_content? "This version has been yanked" assert page.has_css? 'meta[name="robots"][content="noindex"]', visible: false assert page.has_content?("Yanked by") css = %(div.gem__users a[alt=#{@user.handle}]) + assert page.has_css?(css, count: 3) + + assert_event Events::RubygemEvent::VERSION_YANKED, { + number: "2.2.2", + platform: "ruby", + yanked_by: @user.handle, + version_gid: Version.last.to_gid_param, + actor_gid: @user.to_gid.to_s + }, @rubygem.events.where(tag: Events::RubygemEvent::VERSION_YANKED).sole end test "yanked gem entirely then someone else pushes a new version" do create(:version, rubygem: @rubygem, number: "0.0.0") - visit rubygem_path(@rubygem) + visit rubygem_path(@rubygem.slug) + assert page.has_content? "sandworm" assert page.has_content? "0.0.0" page.driver.browser.header("Authorization", @user_api_key) page.driver.delete yank_api_v1_rubygems_path(gem_name: @rubygem.name, version: "0.0.0") - visit rubygem_path(@rubygem) + visit rubygem_path(@rubygem.slug) + assert page.has_content? "sandworm" assert page.has_content? "This gem is not currently hosted on RubyGems.org" other_user_key = "12323" - other_api_key = create(:api_key, key: other_user_key, push_rubygem: true) + other_api_key = create(:api_key, key: other_user_key, scopes: %i[push_rubygem]) build_gem "sandworm", "1.0.0" page.driver.browser.header("Authorization", other_user_key) page.driver.post api_v1_rubygems_path, File.read("sandworm-1.0.0.gem"), "CONTENT_TYPE" => "application/octet-stream" - visit rubygem_path(@rubygem) + visit rubygem_path(@rubygem.slug) + assert page.has_content? "sandworm" assert page.has_content? "1.0.0" assert page.has_selector?("a[alt='#{other_api_key.user.handle}']") @@ -74,6 +89,7 @@ class YankTest < SystemTest end teardown do + RubygemFs.mock! Dir.chdir(Rails.root) end end diff --git a/test/jobs/application_job_test.rb b/test/jobs/application_job_test.rb new file mode 100644 index 00000000000..4107f404beb --- /dev/null +++ b/test/jobs/application_job_test.rb @@ -0,0 +1,31 @@ +require "test_helper" + +class ApplicationJobTest < ActiveSupport::TestCase + test "good_job_concurrency_perform_limit" do + callable = ApplicationJob.good_job_concurrency_perform_limit(default: 12) + + assert_equal 12, ApplicationJob.new.instance_exec(&callable) + + @launch_darkly.update( + @launch_darkly.flag("good_job.concurrency.perform_limit") + .variations(100, 5, 1) + .variation_for_key("active_job", "ApplicationJob", 2) + ) + + assert_equal 1, ApplicationJob.new.instance_exec(&callable) + end + + test "good_job_concurrency_enqueue_limit" do + callable = ApplicationJob.good_job_concurrency_enqueue_limit(default: 12) + + assert_equal 12, ApplicationJob.new.instance_exec(&callable) + + @launch_darkly.update( + @launch_darkly.flag("good_job.concurrency.enqueue_limit") + .variations(100, 5, 1) + .variation_for_key("active_job", "ApplicationJob", 2) + ) + + assert_equal 1, ApplicationJob.new.instance_exec(&callable) + end +end diff --git a/test/jobs/delete_user_job_test.rb b/test/jobs/delete_user_job_test.rb new file mode 100644 index 00000000000..361d13954d3 --- /dev/null +++ b/test/jobs/delete_user_job_test.rb @@ -0,0 +1,166 @@ +require "test_helper" + +class DeleteUserJobTest < ActiveJob::TestCase + test "sends deletion complete on success" do + user = create(:user) + rubygem = create(:ownership, user:).rubygem + version = create(:version, rubygem:) + + assert_delete user + assert_predicate version.reload, :yanked? + refute_predicate rubygem.reload, :indexed? + + rubygem.validate! + version.validate! + end + + test "sends deletion failed on failure" do + user = create(:user) + + Mailer.expects(:deletion_failed).with(user.email).returns(mock(deliver_later: nil)) + User.any_instance.expects(:yank_gems).raises(ActiveRecord::RecordNotDestroyed) + DeleteUserJob.new(user:).perform(user:) + + refute_predicate user.reload, :destroyed? + refute_predicate user, :deleted_at + end + + test "succeeds with api key" do + user = create(:user) + create(:api_key, owner: user) + + assert_delete user + end + + test "succeeds with api key used to push version" do + user = create(:user) + api_key = create(:api_key, owner: user) + version = create(:version, pusher_api_key: api_key, pusher: user) + + assert_delete user + assert_predicate user.reload, :deleted_at + assert_predicate api_key.reload, :expired? + assert_equal version.reload.pusher_api_key, api_key + + User.unscoped do + assert_equal api_key.reload.owner, user + assert_equal version.reload.pusher, user + end + + version.validate! + end + + test "succeeds with version deletion" do + user = create(:user) + rubygem = create(:rubygem, owners: [user]) + api_key = create(:api_key, owner: user) + version = create(:version, rubygem:, pusher_api_key: api_key, pusher: user) + deletion = create(:deletion, version: version, user: user) + + assert_delete user + assert_predicate user.reload, :deleted_at + assert_predicate api_key.reload, :expired? + assert_nil api_key.reload.owner + assert_nil version.reload.pusher + assert_nil deletion.reload.user + + assert_equal version.reload.pusher_api_key, api_key + assert_empty rubygem.reload.owners + assert_empty user.ownerships + assert_predicate version.reload, :yanked? + refute_predicate rubygem.reload, :indexed? + + User.unscoped do + assert_equal api_key.reload.owner, user + assert_equal version.reload.pusher, user + assert_equal deletion.reload.user, user + end + + version.validate! + deletion.validate! + end + + test "succeeds with rubygem with shared ownership" do + user = create(:user) + other_user = create(:user) + rubygem = create(:rubygem, owners: [user, other_user]) + api_key = create(:api_key, owner: user) + version = create(:version, rubygem:, pusher_api_key: api_key, pusher: user) + other_version = create(:version, rubygem:, pusher: other_user) + + assert_delete user + assert_predicate user.reload, :deleted_at + assert_predicate api_key.reload, :expired? + User.unscoped do + assert_equal api_key.reload.owner, user + assert_equal version.reload.pusher, user + end + assert_nil api_key.reload.owner + assert_nil version.reload.pusher + assert_equal version.reload.pusher_api_key, api_key + assert_equal [other_user], rubygem.reload.owners + assert_empty user.ownerships + assert_predicate rubygem.reload, :indexed? + refute_predicate version.reload, :yanked? + refute_predicate other_version.reload, :yanked? + assert_equal other_user, other_version.pusher + end + + test "succeeds with webauthn credentials" do + user = create(:user) + user_credential = create(:webauthn_credential, user: user) + webauthn_verification = create(:webauthn_verification, user: user) + + assert_delete user + assert_deleted user_credential + assert_deleted webauthn_verification + end + + test "succeeds with subscription" do + user = create(:user) + subscription = create(:subscription, user: user) + + assert_delete user + assert_deleted subscription + end + + test "succeeds with ownership calls and requests" do + user = create(:user) + rubygem = create(:rubygem, owners: [user]) + other_user = create(:user) + other_rubygem = create(:rubygem, owners: [other_user]) + + closed_call = create(:ownership_call, rubygem: rubygem, user: user, status: :closed) + open_call = create(:ownership_call, rubygem: rubygem, user: user) + + other_call = create(:ownership_call, rubygem: other_rubygem, user: other_user) + closed_request = create(:ownership_request, ownership_call: other_call, rubygem: other_rubygem, user: user, status: :closed) + approved_request = create(:ownership_request, ownership_call: other_call, rubygem: other_rubygem, user: user, status: :approved) + open_request = create(:ownership_request, ownership_call: other_call, rubygem: other_rubygem, user: user) + other_request = create(:ownership_request, ownership_call: open_call, rubygem: rubygem, user: other_user) + + assert_delete user + assert_deleted open_call + assert_deleted closed_call + assert_deleted other_request + assert_predicate approved_request.reload, :approved? + assert_predicate open_request.reload, :closed? + assert_predicate closed_request.reload, :closed? + assert_equal other_call.reload.user, other_user + end + + def assert_delete(user) + Mailer.expects(:deletion_complete).with(user.email).returns(mock(deliver_later: nil)) + Mailer.expects(:deletion_failed).never + DeleteUserJob.new(user:).perform(user:) + + refute_predicate user.reload, :destroyed? + assert_predicate user.reload, :deleted_at + assert_predicate user.reload, :discarded? + user.validate! + end + + def assert_deleted(record) + refute record.class.unscoped.exists?(record.id), "Expected #{record.class} with id #{record.id} to be deleted" + end +end diff --git a/test/unit/fastly_log_processor_test.rb b/test/jobs/fastly_log_processor_job_test.rb similarity index 75% rename from test/unit/fastly_log_processor_test.rb rename to test/jobs/fastly_log_processor_job_test.rb index df910e69d76..ed5d46b048f 100644 --- a/test/unit/fastly_log_processor_test.rb +++ b/test/jobs/fastly_log_processor_job_test.rb @@ -1,7 +1,7 @@ require "test_helper" -class FastlyLogProcessorTest < ActiveSupport::TestCase - include ESHelper +class FastlyLogProcessorJobTest < ActiveJob::TestCase + include SearchKickHelper setup do @sample_log = Rails.root.join("test", "sample_logs", "fastly-fake.log").read @@ -18,9 +18,10 @@ class FastlyLogProcessorTest < ActiveSupport::TestCase Aws.config[:s3] = { stub_responses: { get_object: { body: @sample_log } } } - @job = FastlyLogProcessor.new("test-bucket", "fastly-fake.log") + @processor = FastlyLogProcessor.new("test-bucket", "fastly-fake.log") + @job = FastlyLogProcessorJob.new(bucket: "test-bucket", key: "fastly-fake.log") create(:gem_download) - Rubygem.__elasticsearch__.create_index! force: true + import_and_refresh end teardown do @@ -30,18 +31,19 @@ class FastlyLogProcessorTest < ActiveSupport::TestCase context "#download_counts" do should "process file from s3" do - assert_equal @sample_log_counts, @job.download_counts(@log_ticket) + assert_equal @sample_log_counts, @processor.download_counts(@log_ticket) end should "process file from local fs" do @log_ticket.update(backend: "local", directory: "test/sample_logs") - assert_equal @sample_log_counts, @job.download_counts(@log_ticket) + + assert_equal @sample_log_counts, @processor.download_counts(@log_ticket) end should "fail if dont find the file" do @log_ticket.update(backend: "local", directory: "foobar") assert_raises FastlyLogProcessor::LogFileNotFoundError do - @job.download_counts(@log_ticket) + @processor.download_counts(@log_ticket) end end end @@ -63,24 +65,27 @@ class FastlyLogProcessorTest < ActiveSupport::TestCase context "#perform" do should "not double count" do json = Rubygem.find_by_name("json") + assert_equal 0, GemDownload.count_for_rubygem(json.id) - 3.times { @job.perform } + 3.times { @job.perform_now } + assert_equal 7, es_downloads(json.id) assert_equal 7, GemDownload.count_for_rubygem(json.id) end should "update download counts" do - @job.perform + @job.perform_now @sample_log_counts .each do |name, expected_count| version = Version.find_by(full_name: name) - if version - count = GemDownload.find_by(rubygem_id: version.rubygem.id, version_id: version.id).count - assert_equal expected_count, count, "invalid value for #{name}" - end + next unless version + count = GemDownload.find_by(rubygem_id: version.rubygem.id, version_id: version.id).count + + assert_equal expected_count, count, "invalid value for #{name}" end json = Rubygem.find_by_name("json") + assert_equal 7, GemDownload.count_for_rubygem(json.id) assert_equal 7, es_downloads(json.id) assert_equal "processed", @log_ticket.reload.status @@ -88,30 +93,33 @@ class FastlyLogProcessorTest < ActiveSupport::TestCase should "not run if already processed" do json = Rubygem.find_by_name("json") + assert_equal 0, json.downloads assert_equal 0, es_downloads(json.id) @log_ticket.update(status: "processed") - @job.perform + @job.perform_now assert_equal 0, es_downloads(json.id) assert_equal 0, json.downloads end should "not mark as processed if anything fails" do - @job.stubs(:download_counts).raises("woops") - assert_raises { @job.perform } + @processor.class.any_instance.stubs(:download_counts).raises("woops") + + assert_kind_of RuntimeError, @job.perform_now refute_equal "processed", @log_ticket.reload.status assert_equal "failed", @log_ticket.reload.status end should "not re-process if it failed" do - @job.stubs(:download_counts).raises("woops") - assert_raises { @job.perform } + @processor.class.any_instance.stubs(:download_counts).raises("woops") + + assert_kind_of RuntimeError, @job.perform_now - @job = FastlyLogProcessor.new("test-bucket", "fastly-fake.log") - @job.perform + @job.perform_now json = Rubygem.find_by_name("json") + assert_equal 0, json.downloads assert_equal 0, es_downloads(json.id) end @@ -119,19 +127,22 @@ class FastlyLogProcessorTest < ActiveSupport::TestCase should "only process the right file" do ticket = LogTicket.create!(backend: "s3", directory: "test-bucket", key: "fastly-fake.2.log", status: "pending") - @job.perform + @job.perform_now + assert_equal "pending", ticket.reload.status assert_equal "processed", @log_ticket.reload.status end should "update the processed count" do - @job.perform + @job.perform_now + assert_equal 10, @log_ticket.reload.processed_count end should "update the total gem count" do assert_equal 0, GemDownload.total_count - @job.perform + @job.perform_now + assert_equal 9, GemDownload.total_count end end diff --git a/test/jobs/fastly_purge_job_test.rb b/test/jobs/fastly_purge_job_test.rb new file mode 100644 index 00000000000..c623689dc6b --- /dev/null +++ b/test/jobs/fastly_purge_job_test.rb @@ -0,0 +1,18 @@ +require "test_helper" + +class FastlyPurgeJobTest < ActiveJob::TestCase + test "calls Fastly.purge with soft: true" do + Fastly.expects(:purge).with({ path: "path", soft: true }) + FastlyPurgeJob.perform_now(path: "path", soft: true) + end + + test "calls Fastly.purge with soft: false" do + Fastly.expects(:purge).with({ path: "path", soft: false }) + FastlyPurgeJob.perform_now(path: "path", soft: false) + end + + test "calls Fastly.purge_key" do + Fastly.expects(:purge_key).with("key", soft: false) + FastlyPurgeJob.perform_now(key: "key", soft: false) + end +end diff --git a/test/jobs/good_job_statsd_job_test.rb b/test/jobs/good_job_statsd_job_test.rb new file mode 100644 index 00000000000..d61a7ed63fd --- /dev/null +++ b/test/jobs/good_job_statsd_job_test.rb @@ -0,0 +1,177 @@ +require "test_helper" + +class GoodJobStatsDJobTest < ActiveSupport::TestCase + include StatsD::Instrument::Assertions + + # opt-out default retries + class FailureJob < ActiveJob::Base # rubocop:disable Rails/ApplicationJob + self.queue_adapter = ActiveJob::QueueAdapters::GoodJobAdapter.new(execution_mode: :async) + queue_as :fail_once + def perform + raise StandardError, "failure" + end + end + + class DiscardJob < ApplicationJob + self.queue_adapter = ActiveJob::QueueAdapters::GoodJobAdapter.new(execution_mode: :async) + discard_on StandardError + def perform + raise StandardError, "discard" + end + end + + class RetryJob < ApplicationJob + self.queue_adapter = ActiveJob::QueueAdapters::GoodJobAdapter.new(execution_mode: :async) + queue_as :retry_once + retry_on StandardError + def perform + raise StandardError, "retry" + end + end + + class SuccessJob < ApplicationJob + self.queue_adapter = ActiveJob::QueueAdapters::GoodJobAdapter.new(execution_mode: :async) + def perform + end + end + + class ConcurrencyLimitedJob < ApplicationJob + self.queue_adapter = ActiveJob::QueueAdapters::GoodJobAdapter.new(execution_mode: :async) + queue_as :concurrency_limited + include GoodJob::ActiveJobExtensions::Concurrency + good_job_control_concurrency_with( + perform_limit: 0, + key: name + ) + def perform + end + end + + def metric(**args) + StatsD::Instrument::MetricExpectation.new(**args.reverse_merge(times: 1)) + end + + setup do + GoodJobStatsDJob.disable_test_adapter + GoodJobStatsDJob.stubs(:queue_adapter).returns(ActiveJob::QueueAdapters::GoodJobAdapter.new(execution_mode: :async)) + end + + test "reports metrics to statsd" do + travel_to Time.utc(2023, 3, 6, 1, 2, 3) + FailureJob.perform_later + DiscardJob.perform_later + RetryJob.perform_later + SuccessJob.perform_later + ConcurrencyLimitedJob.perform_later + + begin + GoodJob.perform_inline("retry_once") + rescue StandardError + nil + end + begin + GoodJob.perform_inline("fail_once") + rescue StandardError + nil + end + GoodJob.perform_inline + + 3.times do + travel 1.day + GoodJob.perform_inline("concurrency_limited") + end + + SuccessJob.set(priority: -2).perform_later + + assert_statsd_calls [ + metric(name: "rails.perform_start.active_job.total_duration", type: :ms, + tags: { "queue" => "stats", "job_class" => "GoodJobStatsDJob", + "adapter" => "ActiveJob::QueueAdapters::GoodJobAdapter", "env" => "test" }), + metric(name: "rails.perform_start.active_job.allocations", type: :h, + tags: { "queue" => "stats", "job_class" => "GoodJobStatsDJob" }), + metric(name: "rails.perform_start.active_job.success", type: :c, + value: 1, + tags: { "queue" => "stats", "job_class" => "GoodJobStatsDJob" }), + + # Retry job + metric(name: "good_job.count", type: :g, + value: 1, + tags: { "state" => "queued", "queue" => "retry_once", "priority" => "0", + "job_class" => "GoodJobStatsDJobTest::RetryJob", "env" => "test" }), + metric(name: "good_job.staleness", type: :g, + tags: { "state" => "queued", "queue" => "retry_once", "priority" => "0", + "job_class" => "GoodJobStatsDJobTest::RetryJob", "env" => "test" }), + + # Concurrency limited job + metric(name: "good_job.count", type: :g, + value: 1, + tags: { "state" => "retried", "queue" => "concurrency_limited", "priority" => "0", + "job_class" => "GoodJobStatsDJobTest::ConcurrencyLimitedJob", "env" => "test" }), + metric(name: "good_job.staleness", type: :g, + value: 3.days, + tags: { "state" => "retried", "queue" => "concurrency_limited", "priority" => "0", + "job_class" => "GoodJobStatsDJobTest::ConcurrencyLimitedJob", "env" => "test" }), + + # Failure job + metric(name: "good_job.count", type: :g, + value: 1, + tags: { "state" => "discarded", "queue" => "fail_once", "priority" => "0", + "job_class" => "GoodJobStatsDJobTest::FailureJob", "env" => "test" }), + metric(name: "good_job.staleness", type: :g, + tags: { "state" => "discarded", "queue" => "fail_once", "priority" => "0", + "job_class" => "GoodJobStatsDJobTest::FailureJob", "env" => "test" }), + + # Success job + metric(name: "good_job.count", type: :g, + value: 1, + tags: { "state" => "queued", "queue" => "default", "priority" => "-2", + "job_class" => "GoodJobStatsDJobTest::SuccessJob", "env" => "test" }), + metric(name: "good_job.count", type: :g, + value: 1, + tags: { "state" => "succeeded", "queue" => "default", "priority" => "0", + "job_class" => "GoodJobStatsDJobTest::SuccessJob", "env" => "test" }), + metric(name: "good_job.staleness", type: :g, + tags: { "state" => "queued", "queue" => "default", "priority" => "-2", + "job_class" => "GoodJobStatsDJobTest::SuccessJob", "env" => "test" }), + metric(name: "good_job.staleness", type: :g, + tags: { "state" => "succeeded", "queue" => "default", "priority" => "0", + "job_class" => "GoodJobStatsDJobTest::SuccessJob", "env" => "test" }), + + # Discard job + metric(name: "good_job.count", type: :g, + value: 1, + tags: { "state" => "discarded", "queue" => "default", "priority" => "0", + "job_class" => "GoodJobStatsDJobTest::DiscardJob", "env" => "test" }), + metric(name: "good_job.staleness", type: :g, + tags: { "state" => "discarded", "queue" => "default", "priority" => "0", + "job_class" => "GoodJobStatsDJobTest::DiscardJob", "env" => "test" }), + + metric(name: "rails.perform.active_job.total_duration", type: :ms, + tags: { "queue" => "stats", "job_class" => "GoodJobStatsDJob" }), + metric(name: "rails.perform.active_job.allocations", type: :h, + tags: { "queue" => "stats", "job_class" => "GoodJobStatsDJob" }), + metric(name: "rails.perform.active_job.success", type: :c, + value: 1, + tags: { "queue" => "stats", "job_class" => "GoodJobStatsDJob" }) + ] do + GoodJobStatsDJob.perform_now + end + end + + # covering unexpected GoodJob states enum change + class BrokenFilter + def states + { "invalid_state" => [] } + end + end + + test "invalid state" do + GoodJobStatsDJob::Filter.stubs(:new).returns(BrokenFilter.new) + + error = assert_raises(StandardError) do + GoodJobStatsDJob.new.perform + end + + assert_equal("unknown GoodJob state 'invalid_state'", error.message) + end +end diff --git a/test/jobs/hook_relay_report_job_test.rb b/test/jobs/hook_relay_report_job_test.rb new file mode 100644 index 00000000000..e825af79e29 --- /dev/null +++ b/test/jobs/hook_relay_report_job_test.rb @@ -0,0 +1,39 @@ +require "test_helper" + +class HookRelayReportJobTest < ActiveJob::TestCase + setup do + @webhook = create(:web_hook) + end + + test "discards on unknown status" do + assert_nothing_raised do + HookRelayReportJob.perform_now({ status: "unknown", stream: ":webhook_id-#{@webhook.id}", completed_at: "2020-01-01" }) + end + end + + test "discards on malformed stream" do + assert_nothing_raised do + HookRelayReportJob.perform_now({ status: "unknown", stream: ":webhook_idZZZ-#{@webhook.id}", completed_at: "2020-01-01" }) + end + end + + test "calls success!" do + completed_at = 1.minute.ago + + assert_difference -> { @webhook.reload.successes_since_last_failure } do + HookRelayReportJob.perform_now( + { status: "success", stream: ":webhook_id-#{@webhook.id}", completed_at: completed_at.as_json } + ) + end + end + + test "calls failure!" do + completed_at = 1.minute.ago + + assert_difference -> { @webhook.reload.failures_since_last_success } do + HookRelayReportJob.perform_now( + { status: "failure", stream: ":webhook_id-#{@webhook.id}", completed_at: completed_at.as_json } + ) + end + end +end diff --git a/test/jobs/mfa_usage_stats_job_test.rb b/test/jobs/mfa_usage_stats_job_test.rb new file mode 100644 index 00000000000..cd9b63f8953 --- /dev/null +++ b/test/jobs/mfa_usage_stats_job_test.rb @@ -0,0 +1,41 @@ +require "test_helper" + +class MfaUsageStatsJobTest < ActiveJob::TestCase + include StatsD::Instrument::Assertions + + setup do + create(:user) # non-mfa user + 2.times { create(:user).enable_totp!(ROTP::Base32.random_base32, :ui_and_api) } # otp-only users + 3.times { create(:webauthn_credential, user: create(:user)) } # webauthn-only users + # webauthn-and-otp users + 4.times do + user = create(:user) + user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + create(:webauthn_credential, user: user) + end + end + + test "it sends the count of non-MFA users to statsd" do + assert_statsd_gauge("mfa_usage_stats.non_mfa_users", 1) do + MfaUsageStatsJob.perform_now + end + end + + test "it sends the count of OTP-only users to statsd" do + assert_statsd_gauge("mfa_usage_stats.totp_only_users", 2) do + MfaUsageStatsJob.perform_now + end + end + + test "it sends the count of WebAuthn-only users to statsd" do + assert_statsd_gauge("mfa_usage_stats.webauthn_only_users", 3) do + MfaUsageStatsJob.perform_now + end + end + + test "it sends the count of WebAuthn-and-OTP users to statsd" do + assert_statsd_gauge("mfa_usage_stats.webauthn_and_totp_users", 4) do + MfaUsageStatsJob.perform_now + end + end +end diff --git a/test/jobs/notify_web_hook_job_test.rb b/test/jobs/notify_web_hook_job_test.rb new file mode 100644 index 00000000000..88425ca8012 --- /dev/null +++ b/test/jobs/notify_web_hook_job_test.rb @@ -0,0 +1,87 @@ +require "test_helper" + +class NotifyWebHookJobTest < ActiveJob::TestCase + context "with a rubygem and version" do + setup do + @rubygem = create(:rubygem, name: "foogem", downloads: 42) + @version = create(:version, + rubygem: @rubygem, + number: "3.2.1", + authors: %w[AUTHORS], + description: "DESC") + @hook = create(:web_hook, rubygem: @rubygem) + @job = NotifyWebHookJob.new(webhook: @hook, protocol: "http", host_with_port: "localhost:1234", version: @version) + end + + should "have gem properties encoded in JSON" do + payload = @job.run_callbacks(:perform) { JSON.parse(@job.payload) } + + assert_equal "foogem", payload["name"] + assert_equal "3.2.1", payload["version"] + assert_equal "ruby", payload["platform"] + assert_equal "DESC", payload["info"] + assert_equal "AUTHORS", payload["authors"] + assert_equal 42, payload["downloads"] + assert_equal "http://localhost:1234/gems/foogem", payload["project_uri"] + assert_equal "http://localhost:1234/gems/foogem-3.2.1.gem", payload["gem_uri"] + end + + should "send the right version out even for older gems" do + new_version = create(:version, number: "2.0.0", rubygem: @rubygem) + new_hook = create(:web_hook) + job = NotifyWebHookJob.new(webhook: new_hook, protocol: "http", host_with_port: "localhost:1234", version: new_version) + payload = job.run_callbacks(:perform) { JSON.parse(job.payload) } + + assert_equal "foogem", payload["name"] + assert_equal "2.0.0", payload["version"] + assert_equal "http://localhost:1234/gems/foogem", payload["project_uri"] + assert_equal "http://localhost:1234/gems/foogem-2.0.0.gem", payload["gem_uri"] + end + end + + context "with a successful request" do + setup do + @hook = create(:web_hook) + @job = NotifyWebHookJob.new(webhook: @hook, protocol: "http", host_with_port: "localhost:1234", version: create(:version)) + end + + should "succeed with hook relay" do + stub_request(:post, "https://api.hookrelay.dev/hooks///webhook_id-#{@hook.id}") + .with(headers: { + "Content-Type" => "application/json", + "HR_TARGET_URL" => @hook.url, + "HR_MAX_ATTEMPTS" => "3" + }).to_return_json(status: 200, body: { id: 12_345 }) + + perform_enqueued_jobs do + @job.enqueue + end + + assert_performed_jobs 1, only: NotifyWebHookJob + assert_enqueued_jobs 0, only: NotifyWebHookJob + end + end + + context "with an invalid URL" do + setup do + @hook = create(:web_hook) + @job = NotifyWebHookJob.new(webhook: @hook, protocol: "http", host_with_port: "localhost:1234", version: create(:version)) + end + + should "discard the job on a 422 with hook relay" do + stub_request(:post, "https://api.hookrelay.dev/hooks///webhook_id-#{@hook.id}") + .with(headers: { + "Content-Type" => "application/json", + "HR_TARGET_URL" => @hook.url, + "HR_MAX_ATTEMPTS" => "3" + }).to_return_json(status: 422, body: { error: "Invalid url" }) + + perform_enqueued_jobs do + @job.enqueue + end + + assert_performed_jobs 1, only: NotifyWebHookJob + assert_enqueued_jobs 0, only: NotifyWebHookJob + end + end +end diff --git a/test/jobs/process_sendgrid_event_job_test.rb b/test/jobs/process_sendgrid_event_job_test.rb new file mode 100644 index 00000000000..dee1e8ca09c --- /dev/null +++ b/test/jobs/process_sendgrid_event_job_test.rb @@ -0,0 +1,9 @@ +require "test_helper" + +class ProcessSendgridEventJobTest < ActiveJob::TestCase + test "calls #process" do + SendgridEvent.any_instance.expects(:process) + + ProcessSendgridEventJob.perform_now(sendgrid_event: create(:sendgrid_event)) + end +end diff --git a/test/jobs/refresh_oidc_provider_job_test.rb b/test/jobs/refresh_oidc_provider_job_test.rb new file mode 100644 index 00000000000..d90a7d2be12 --- /dev/null +++ b/test/jobs/refresh_oidc_provider_job_test.rb @@ -0,0 +1,156 @@ +require "test_helper" + +class RefreshOIDCProviderJobTest < ActiveJob::TestCase + setup do + @provider = create(:oidc_provider, issuer: "https://token.actions.githubusercontent.com", configuration: nil, jwks: nil) + end + + def stub_requests(config_status: 200, jwks_status: 200, config_body: {}, jwks_body: {}, config_headers: {}, jwks_headers: {}) # rubocop:disable Metrics/MethodLength,Metrics/ParameterLists + stub_request(:get, "https://token.actions.githubusercontent.com/.well-known/openid-configuration").to_return( + status: config_status, + body: { + issuer: "https://token.actions.githubusercontent.com", + jwks_uri: "https://token.actions.githubusercontent.com/.well-known/jwks", + subject_types_supported: %w[public pairwise], + response_types_supported: ["id_token"], + claims_supported: %w[sub aud exp iat iss jti nbf ref repository repository_id repository_owner repository_owner_id run_id run_number + run_attempt actor actor_id workflow workflow_ref workflow_sha head_ref base_ref event_name ref_type environment + environment_node_id job_workflow_ref job_workflow_sha repository_visibility runner_environment], + id_token_signing_alg_values_supported: [ + "RS256" + ], + scopes_supported: [ + "openid" + ] + }.then { |body| config_body.is_a?(Hash) ? body.merge(config_body).to_json : config_body }, + headers: { "content-type" => "application/json; charset=utf-8" }.merge(config_headers) + ) + stub_request(:get, "https://token.actions.githubusercontent.com/.well-known/jwks").to_return( + status: jwks_status, + body: { + keys: [ + { + n: "4WpHpoBYsVBVfSlfgnRbdPMxP3Eb7rFqE48e4pPM4qH_9EsUZIi21LjOu8UkKn14L4hrRfzfRHG7VQSbxXBU1Qa-xM5yVxdmfQZKBxQnPWaE1v7edjxq1ZYnqHIp90Uvn" \ + "w6798xMCSvI_V3FR8tix5GaoTgkixXlPc-ozifMyEZMmhvuhfDsSxQeTSHGPlWfGkX0id_gYzKPeI69EGtQ9ZN3PLTdoAI8jxlQ-jyDchi9h2ax6hgMLDsMZyiIXnF2UY" \ + "q4j36Cs5RgdC296d0hEOHN0WYZE-xPl7y_A9UHcVjrxeGfVOuTBXqjowofimn4ESnVXNReCsOwZCJlvJzfpQ", + kty: "RSA", + kid: "78167F727DEC5D801DD1C8784C704A1C880EC0E1", + alg: "RS256", + e: "AQAB", + use: "sig", + x5c: [ + "MIIDrDCCApSgAwIBAgIQMPdKi0TFTMqmg1HHo6FfsDANBgkqhkiG9w0BAQsFADA2MTQwMgYDVQQDEyt2c3RzLXZzdHNnaHJ0LWdoLXZzby1vYXV0aC52aXN1YWxzdHVkaW8" \ + "uY29tMB4XDTIyMDEwNTE4NDcyMloXDTI0MDEwNTE4NTcyMlowNjE0MDIGA1UEAxMrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTCCASIwDQ" \ + "YJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOFqR6aAWLFQVX0pX4J0W3TzMT9xG+6xahOPHuKTzOKh//RLFGSIttS4zrvFJCp9eC+Ia0X830Rxu1UEm8VwVNUGvsTOclcXZ" \ + "n0GSgcUJz1mhNb+3nY8atWWJ6hyKfdFL58Ou/fMTAkryP1dxUfLYseRmqE4JIsV5T3PqM4nzMhGTJob7oXw7EsUHk0hxj5VnxpF9Inf4GMyj3iOvRBrUPWTdzy03aACPI8Z" \ + "UPo8g3IYvYdmseoYDCw7DGcoiF5xdlGKuI9+grOUYHQtvendIRDhzdFmGRPsT5e8vwPVB3FY68Xhn1TrkwV6o6MKH4pp+BEp1VzUXgrDsGQiZbyc36UCAwEAAaOBtTCBsjA" \ + "OBgNVHQ8BAf8EBAMCBaAwCQYDVR0TBAIwADAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwNgYDVR0RBC8wLYIrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudm" \ + "lzdWFsc3R1ZGlvLmNvbTAfBgNVHSMEGDAWgBRZBaZCR9ghvStfcWaGwuHGjrfTgzAdBgNVHQ4EFgQUWQWmQkfYIb0rX3FmhsLhxo6304MwDQYJKoZIhvcNAQELBQADggEBA" \ + "GNdfALe6mdxQ67QL8GlW4dfFwvCX87JOeZThZ9uCj1+x1xUnywoR4o5q2DVI/JCvBRPn0BUb3dEVWLECXDHGjblesWZGMdSGYhMzWRQjVNmCYBC1ZM5QvonWCBcGkd72mZx" \ + "0eFHnJCAP/TqEEpRvMHR+OOtSiZWV9zZpF1tf06AjKwT64F9V8PCmSIqPJXcTQXKKfkHZmGUk9AYF875+/FfzF89tCnT53UEh5BldFz0SAls+NhexbW/oOokBNCVqe+T2xX" \ + "izktbFnFAFaomvwjVSvIeu3i/0Ygywl+3s5izMEsZ1T1ydIytv4FZf2JCHgRpmGPWJ5A7TpxuHSiE8Do=" + ], + x5t: "eBZ_cn3sXYAd0ch4THBKHIgOwOE" + }, + { + n: "wgCsNL8S6evSH_AHBsps2ccIHSwLpuEUGS9GYenGmGkSKyWefKsZheKl_84voiUgduuKcKA2aWQezp9338LjtlBmTHjopzAeU-Q3_IvqNf7BfrEAzEyp-ymdhNzPTE7S" \ + "nmr5o_9AeiP1ZDBo35FaULgVUECJ3AzAM36zkURax3VNZRRZx1gb8lPUs9M5Yw6aZpHSOd6q_QzE8CP1OhGrAdoBzZ6ZCElon0kI-IuRLCwKptS7Yroi5-RtEKD2W458" \ + "axNAQ36Yw93N8kInUC1QZDPrKd4QfYiG68ywjBoxp_bjNg5kh4LJmq1mwyGdNQV6F1Ew_jYlmou2Y8wvHQRJPQ", + kty: "RSA", + kid: "52F197C481DE70112C441B4A9B37B53C7FCF0DB5", + alg: "RS256", + e: "AQAB", + use: "sig", + x5c: [ + "MIIDrDCCApSgAwIBAgIQLQnoXJ3HT6uPYvEofvOZ6zANBgkqhkiG9w0BAQsFADA2MTQwMgYDVQQDEyt2c3RzLXZzdHNnaHJ0LWdoLXZzby1vYXV0aC52aXN1YWxzdHVka" \ + "W8uY29tMB4XDTIxMTIwNjE5MDUyMloXDTIzMTIwNjE5MTUyMlowNjE0MDIGA1UEAxMrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTCCAS" \ + "IwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMIArDS/Eunr0h/wBwbKbNnHCB0sC6bhFBkvRmHpxphpEislnnyrGYXipf/OL6IlIHbrinCgNmlkHs6fd9/C47ZQZkx" \ + "46KcwHlPkN/yL6jX+wX6xAMxMqfspnYTcz0xO0p5q+aP/QHoj9WQwaN+RWlC4FVBAidwMwDN+s5FEWsd1TWUUWcdYG/JT1LPTOWMOmmaR0jneqv0MxPAj9ToRqwHaAc2e" \ + "mQhJaJ9JCPiLkSwsCqbUu2K6IufkbRCg9luOfGsTQEN+mMPdzfJCJ1AtUGQz6yneEH2IhuvMsIwaMaf24zYOZIeCyZqtZsMhnTUFehdRMP42JZqLtmPMLx0EST0CAwEAA" \ + "aOBtTCBsjAOBgNVHQ8BAf8EBAMCBaAwCQYDVR0TBAIwADAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwNgYDVR0RBC8wLYIrdnN0cy12c3RzZ2hydC1naC12c2" \ + "8tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTAfBgNVHSMEGDAWgBTTNQQWmG4PZZsdfMeamCH1YcyDZTAdBgNVHQ4EFgQU0zUEFphuD2WbHXzHmpgh9WHMg2UwDQYJKoZIhvc" \ + "NAQELBQADggEBAK/d+HzBSRac7p6CTEolRXcBrBmmeJUDbBy20/XA6/lmKq73dgc/za5VA6Kpfd6EFmG119tl2rVGBMkQwRx8Ksr62JxmCw3DaEhE8ZjRARhzgSiljqXH" \ + "lk8TbNnKswHxWmi4MD2/8QhHJwFj3X35RrdMM4R0dN/ojLlWsY9jXMOAvcSBQPBqttn/BjNzvn93GDrVafyX9CPl8wH40MuWS/gZtXeYIQg5geQkHCyP96M5Sy8ZABOo9" \ + "MSIfPRw1F7dqzVuvliul9ZZGV2LsxmZCBtbsCkBau0amerigZjud8e9SNp0gaJ6wGhLbstCZIdaAzS5mSHVDceQzLrX2oe1h4k=" + ], + x5t: "UvGXxIHecBEsRBtKmze1PH_PDbU" + } + ] + }.then { |body| jwks_body.is_a?(Hash) ? body.merge(jwks_body).to_json : jwks_body }, + headers: { "content-type" => "application/json; charset=utf-8" }.merge(jwks_headers) + ) + end + + context "on success" do + setup do + stub_requests + end + + should "update the provider" do + job = RefreshOIDCProviderJob.new(provider: @provider) + + assert job.perform_now + + assert_equal 1, job.executions + + assert_equal "https://token.actions.githubusercontent.com", @provider.reload.issuer + refute_nil(@provider.configuration) + refute_nil(@provider.jwks) + end + end + + context "when the configuration endpoint returns a 404" do + setup do + stub_requests(config_status: 404) + end + + should "raise an error" do + assert_kind_of Faraday::ResourceNotFound, RefreshOIDCProviderJob.perform_now(provider: @provider) + assert_nil @provider.reload.configuration + end + end + + context "when the configuration endpoint returns an invalid configuration" do + setup do + stub_requests(config_body: { "jwks_uri" => nil }) + end + + should "raise an error" do + assert_kind_of OpenIDConnect::ValidationFailed, RefreshOIDCProviderJob.perform_now(provider: @provider) + assert_nil @provider.reload.configuration + end + end + + context "when the jwks endpoint returns a 404" do + setup do + stub_requests(jwks_status: 404) + end + + should "raise an error" do + assert_kind_of Faraday::ResourceNotFound, RefreshOIDCProviderJob.perform_now(provider: @provider) + assert_nil @provider.reload.configuration + end + end + + context "when the config endpoint returns non-json" do + setup do + stub_requests(config_headers: { "content-type" => "text/plain" }, config_body: "not json") + end + + should "raise an error" do + assert_kind_of Faraday::ParsingError, RefreshOIDCProviderJob.perform_now(provider: @provider) + assert_nil @provider.reload.configuration + end + end + + context "when the config endpoint returns a different URI for jwks" do + setup do + stub_requests(config_body: { jwks_uri: "https://example.com/jwks" }) + end + + should "raise an error" do + assert_kind_of RefreshOIDCProviderJob::JWKSURIMismatchError, RefreshOIDCProviderJob.perform_now(provider: @provider) + assert_nil @provider.reload.configuration + end + end +end diff --git a/test/jobs/refresh_oidc_providers_job_test.rb b/test/jobs/refresh_oidc_providers_job_test.rb new file mode 100644 index 00000000000..9ad6f795703 --- /dev/null +++ b/test/jobs/refresh_oidc_providers_job_test.rb @@ -0,0 +1,16 @@ +require "test_helper" + +class RefreshOIDCProvidersJobTest < ActiveJob::TestCase + test "enqueues refresh jobs" do + provider1 = create(:oidc_provider) + provider2 = create(:oidc_provider) + + assert_enqueued_jobs 2, only: RefreshOIDCProviderJob do + assert_enqueued_with(job: RefreshOIDCProviderJob, args: [{ provider: provider1 }]) do + assert_enqueued_with(job: RefreshOIDCProviderJob, args: [{ provider: provider2 }]) do + RefreshOIDCProvidersJob.perform_now + end + end + end + end +end diff --git a/test/jobs/rstuf/add_job_test.rb b/test/jobs/rstuf/add_job_test.rb new file mode 100644 index 00000000000..56acaa31660 --- /dev/null +++ b/test/jobs/rstuf/add_job_test.rb @@ -0,0 +1,27 @@ +require "test_helper" + +class Rstuf::AddJobTest < ActiveJob::TestCase + setup do + setup_rstuf + + @version = create(:version) + @task_id = "12345" + + stub_request(:post, "#{Rstuf.base_url}/api/v1/artifacts/") + .to_return( + status: 200, + body: { data: { task_id: @task_id } }.to_json, + headers: { "Content-Type" => "application/json" } + ) + end + + test "perform_later enqueues CheckJob with task_id" do + assert_enqueued_with(at: Time.zone.now + Rstuf.wait_for, job: Rstuf::CheckJob, args: [@task_id]) do + Rstuf::AddJob.perform_now(version: @version) + end + end + + teardown do + teardown_rstuf + end +end diff --git a/test/jobs/rstuf/application_job_test.rb b/test/jobs/rstuf/application_job_test.rb new file mode 100644 index 00000000000..4bcf8be4be8 --- /dev/null +++ b/test/jobs/rstuf/application_job_test.rb @@ -0,0 +1,31 @@ +require "test_helper" + +class Rstuf::ApplicationJobTest < ActiveJob::TestCase + class MockJob < Rstuf::ApplicationJob + def perform + # no-op + end + end + + setup do + setup_rstuf + end + + test "job is not performed if Rstuf is disabled" do + Rstuf.enabled = false + assert_no_enqueued_jobs only: MockJob do + MockJob.perform_later + end + end + + test "job is performed if Rstuf is enabled" do + Rstuf.enabled = true + assert_enqueued_jobs 1, only: MockJob do + MockJob.perform_later + end + end + + teardown do + teardown_rstuf + end +end diff --git a/test/jobs/rstuf/check_job_test.rb b/test/jobs/rstuf/check_job_test.rb new file mode 100644 index 00000000000..d45a535de96 --- /dev/null +++ b/test/jobs/rstuf/check_job_test.rb @@ -0,0 +1,63 @@ +require "test_helper" + +class Rstuf::CheckJobTest < ActiveJob::TestCase + setup do + setup_rstuf + + @task_id = "task123" + end + + test "perform does not raise on success" do + success_response = { "data" => { "state" => "SUCCESS" } } + stub_request(:get, "#{Rstuf.base_url}/api/v1/task/?task_id=#{@task_id}") + .to_return(status: 200, body: success_response.to_json, headers: { "Content-Type" => "application/json" }) + + assert_nothing_raised do + Rstuf::CheckJob.perform_now(@task_id) + end + end + + test "perform raises an error on error" do + failure_response = { "data" => { "state" => "ERRORED" } } + stub_request(:get, "#{Rstuf.base_url}/api/v1/task/?task_id=#{@task_id}") + .to_return(status: 200, body: failure_response.to_json, headers: { "Content-Type" => "application/json" }) + + assert_raises(Rstuf::CheckJob::ErrorException) do + Rstuf::CheckJob.new.perform(@task_id) + end + end + + test "perform raises an error on failure" do + failure_response = { "data" => { "state" => "FAILURE" } } + stub_request(:get, "#{Rstuf.base_url}/api/v1/task/?task_id=#{@task_id}") + .to_return(status: 200, body: failure_response.to_json, headers: { "Content-Type" => "application/json" }) + + assert_raises(Rstuf::CheckJob::FailureException) do + Rstuf::CheckJob.new.perform(@task_id) + end + end + + test "perform raises a retry exception on pending state and retries" do + retry_response = { "data" => { "state" => "PENDING" } } + stub_request(:get, "#{Rstuf.base_url}/api/v1/task/?task_id=#{@task_id}") + .to_return(status: 200, body: retry_response.to_json, headers: { "Content-Type" => "application/json" }) + + assert_enqueued_with(job: Rstuf::CheckJob, args: [@task_id]) do + Rstuf::CheckJob.perform_now(@task_id) + end + end + + test "perform raises a retry exception on retry state and retries" do + retry_response = { "data" => { "state" => "UNKNOWN" } } + stub_request(:get, "#{Rstuf.base_url}/api/v1/task/?task_id=#{@task_id}") + .to_return(status: 200, body: retry_response.to_json, headers: { "Content-Type" => "application/json" }) + + assert_enqueued_with(job: Rstuf::CheckJob, args: [@task_id]) do + Rstuf::CheckJob.perform_now(@task_id) + end + end + + teardown do + teardown_rstuf + end +end diff --git a/test/jobs/rstuf/remove_job_test.rb b/test/jobs/rstuf/remove_job_test.rb new file mode 100644 index 00000000000..5bfa65bee6d --- /dev/null +++ b/test/jobs/rstuf/remove_job_test.rb @@ -0,0 +1,27 @@ +require "test_helper" + +class Rstuf::RemoveJobTest < ActiveJob::TestCase + setup do + setup_rstuf + + @version = create(:version) + @task_id = "67890" + + stub_request(:post, "#{Rstuf.base_url}/api/v1/artifacts/delete") + .to_return( + status: 200, + body: { data: { task_id: @task_id } }.to_json, + headers: { "Content-Type" => "application/json" } + ) + end + + test "perform_later enqueues CheckJob with task_id" do + assert_enqueued_with(at: Time.zone.now + Rstuf.wait_for, job: Rstuf::CheckJob, args: [@task_id]) do + Rstuf::RemoveJob.perform_now(version: @version) + end + end + + teardown do + teardown_rstuf + end +end diff --git a/test/jobs/store_version_contents_job_test.rb b/test/jobs/store_version_contents_job_test.rb new file mode 100644 index 00000000000..42344c95da0 --- /dev/null +++ b/test/jobs/store_version_contents_job_test.rb @@ -0,0 +1,192 @@ +require "test_helper" + +class StoreVersionContentsJobTest < ActiveJob::TestCase + include ActiveJob::TestHelper + + setup do + RubygemFs.mock! + + @gem = gem_file("bin_and_img-0.1.0.gem") + @user = create(:user) + pusher = Pusher.new(create(:api_key, owner: @user), @gem) + + assert pusher.process, "gem should be pushed successfully: #{pusher.code} #{pusher.message}" + @gem.rewind + @gem_package = Gem::Package.new(@gem) + @version = Version.last + + @destination_dir = Rails.root.join("tmp", "gems", @gem_package.spec.full_name) + @gem_package.extract_files(@destination_dir.to_s) # TODO: expand once per test run? + end + + def each_file_in_gem + @destination_dir.glob("**/*", File::FNM_DOTMATCH).each do |pathname| + next if pathname.directory? + relative_path = pathname.relative_path_from(@destination_dir).to_s + yield pathname, relative_path + end + end + + teardown do + @gem&.close + RubygemFs.mock! + end + + def perform(version: @version) + StoreVersionContentsJob.perform_now(version:) + end + + def perform_retries(version: @version) + ActiveJob::Base.queue_adapter.perform_enqueued_at_jobs = true + perform(version:) + ensure + ActiveJob::Base.queue_adapter.perform_enqueued_at_jobs = false + end + + context "a not found version" do + should "raise on nil" do + assert_raises(StoreVersionContentsJob::VersionNotIndexed) do + perform_retries(version: nil) + end + end + end + + context "a not found gem" do + should "raise on nil" do + RubygemFs.instance.remove("gems/bin_and_img-0.1.0.gem") + + assert_raises(StoreVersionContentsJob::GemNotFound) do + perform_retries + end + end + end + + context "a corrupt gem" do + should "raise Gem::Package::FormatError and discard job" do + RubygemFs.instance.store("gems/bin_and_img-0.1.0.gem", "corrupt gem") + + assert_no_enqueued_jobs(only: StoreVersionContentsJob) do + assert_raises(Gem::Package::FormatError) do + perform + end + end + end + end + + context "a yanked gem" do + setup do + @version.update!(indexed: false, yanked_at: Time.now.utc) + end + + should "exit processing before doing anything" do + assert_predicate @version, :yanked?, "version should be yanked" + + assert_raises(StoreVersionContentsJob::VersionNotIndexed) do + perform_retries + end + + assert_nil @version.manifest.spec + assert_predicate @version.manifest.paths, :empty? + assert_predicate @version.manifest.checksums, :empty? + assert_predicate @version.manifest.contents.keys, :empty? + end + end + + context "a valid gem" do + should "store gem spec" do + perform + + assert_equal @gem_package.spec.to_ruby, @version.manifest.spec + end + + should "store checksums" do + perform + + checksums = {} + each_file_in_gem do |pathname, relative_path| + checksums[relative_path] = Digest::SHA256.hexdigest(pathname.symlink? ? pathname.readlink.to_s : pathname.binread) + end + + assert_predicate checksums, :any?, "gem source should have some checksums" + assert_equal checksums, @version.manifest.checksums + end + + should "store all the paths from the gem" do + perform + + assert_equal @gem_package.contents.sort, @version.manifest.paths.sort + end + + should "record symlinks" do + perform + + each_file_in_gem do |pathname, relative_path| + next unless pathname.symlink? + entry = @version.manifest.entry(relative_path) + + assert_predicate entry, :symlink?, "symlink? should match for #{relative_path}" + assert_equal pathname.readlink.to_s, entry.linkname, "linkname should match for symlink at #{relative_path}" + end + end + + should "record sha256 hexdigest of each file" do + perform + + each_file_in_gem do |pathname, relative_path| + entry = @version.manifest.entry(relative_path) + if pathname.symlink? + assert_equal Digest::SHA256.hexdigest(pathname.readlink.to_s), entry.sha256, "sha256 should match for #{relative_path}" + else + assert_equal Digest::SHA256.hexdigest(pathname.binread), entry.sha256, "sha256 should match for #{relative_path}" + end + end + end + + should "record file mime types" do + perform + + each_file_in_gem do |pathname, relative_path| + next if pathname.symlink? + entry = @version.manifest.entry(relative_path) + + assert_equal Magic.file(pathname.to_s, Magic::MIME), entry.mime, "mime should match for #{relative_path}" + end + end + + should "record file size" do + perform + + each_file_in_gem do |pathname, relative_path| + size = pathname.symlink? ? 0 : pathname.size + entry = @version.manifest.entry(relative_path) + + assert_equal size, entry.size, "size should match for #{relative_path}" + end + end + + should "record file mode" do + perform + + each_file_in_gem do |pathname, relative_path| + next if pathname.symlink? # symlink mode is inconsistent across platforms + entry = @version.manifest.entry(relative_path) + fs_mode = (pathname.stat.mode & 0o777).to_fs(8) # only compare the last 3 octal digits + + assert_equal fs_mode, entry.file_mode.last(3), "mode should match for #{relative_path}" + end + end + + should "store all text file contents and attributes" do + perform + + each_file_in_gem do |pathname, relative_path| + next if pathname.symlink? + body = pathname.binread + next unless Magic.buffer(body, Magic::MIME).start_with?("text/") + entry = @version.manifest.entry(relative_path) + + assert_equal body, entry.body, "contents should match for #{relative_path}" + end + end + end +end diff --git a/test/jobs/upload_info_file_job_test.rb b/test/jobs/upload_info_file_job_test.rb new file mode 100644 index 00000000000..f6780421052 --- /dev/null +++ b/test/jobs/upload_info_file_job_test.rb @@ -0,0 +1,45 @@ +require "test_helper" + +class UploadInfoFileJobTest < ActiveJob::TestCase + make_my_diffs_pretty! + + test "uploads the info file" do + version = create(:version, number: "0.0.1", required_ruby_version: ">= 2.0.0", required_rubygems_version: ">= 2.6.3") + + perform_enqueued_jobs only: [UploadInfoFileJob] do + UploadInfoFileJob.perform_now(rubygem_name: version.rubygem.name) + end + + content = <<~INFO + --- + 0.0.1 |checksum:b5d4045c3f466fa91fe2cc6abe79232a1a57cdf104f7a26e716e0a1e2789df78,ruby:>= 2.0.0,rubygems:>= 2.6.3 + INFO + + assert_equal content, RubygemFs.compact_index.get("info/#{version.rubygem.name}") + + assert_equal( + { + metadata: { + "surrogate-control" => "max-age=3600, stale-while-revalidate=1800", + "surrogate-key" => + "info/* info/#{version.rubygem.name} gem/#{version.rubygem.name} s3-compact-index s3-info/* s3-info/#{version.rubygem.name}", + "sha256" => Digest::SHA256.base64digest(content), + "md5" => Digest::MD5.base64digest(content) + }, + cache_control: "max-age=60, public", + content_type: "text/plain; charset=utf-8", + checksum_sha256: Digest::SHA256.base64digest(content), + content_md5: Digest::MD5.base64digest(content), + key: "info/#{version.rubygem.name}" + }, RubygemFs.compact_index.head("info/#{version.rubygem.name}") + ) + + assert_enqueued_with(job: FastlyPurgeJob, args: [{ key: "s3-info/#{version.rubygem.name}", soft: true }]) + end + + test "#good_job_concurrency_key" do + job = UploadInfoFileJob.new(rubygem_name: "foo") + + assert_equal "UploadInfoFileJob:foo", job.good_job_concurrency_key + end +end diff --git a/test/jobs/upload_names_file_job_test.rb b/test/jobs/upload_names_file_job_test.rb new file mode 100644 index 00000000000..09437026bcb --- /dev/null +++ b/test/jobs/upload_names_file_job_test.rb @@ -0,0 +1,45 @@ +require "test_helper" + +class UploadNamesFileJobTest < ActiveJob::TestCase + make_my_diffs_pretty! + + test "uploads the names file" do + version = create(:version, number: "0.0.1", required_ruby_version: ">= 2.0.0", required_rubygems_version: ">= 2.6.3") + + perform_enqueued_jobs only: [UploadNamesFileJob] do + UploadNamesFileJob.perform_now + end + + content = <<~INFO + --- + #{version.rubygem.name} + INFO + + assert_equal content, RubygemFs.compact_index.get("names") + + assert_equal( + { + metadata: { + "surrogate-control" => "max-age=3600, stale-while-revalidate=1800", + "surrogate-key" => + "names s3-compact-index s3-names", + "sha256" => Digest::SHA256.base64digest(content), + "md5" => Digest::MD5.base64digest(content) + }, + cache_control: "max-age=60, public", + content_type: "text/plain; charset=utf-8", + checksum_sha256: Digest::SHA256.base64digest(content), + content_md5: Digest::MD5.base64digest(content), + key: "names" + }, RubygemFs.compact_index.head("names") + ) + + assert_enqueued_with(job: FastlyPurgeJob, args: [{ key: "s3-names", soft: true }]) + end + + test "#good_job_concurrency_key" do + job = UploadNamesFileJob.new + + assert_equal "UploadNamesFileJob", job.good_job_concurrency_key + end +end diff --git a/test/jobs/upload_versions_file_job_test.rb b/test/jobs/upload_versions_file_job_test.rb new file mode 100644 index 00000000000..6aaf266f283 --- /dev/null +++ b/test/jobs/upload_versions_file_job_test.rb @@ -0,0 +1,39 @@ +require "test_helper" + +class UploadVersionsFileJobTest < ActiveJob::TestCase + test "uploads the versions file" do + version = create(:version) + info_checksum = GemInfo.new(version.rubygem.name).info_checksum + version.update!(info_checksum:) + + UploadVersionsFileJob.perform_now + + content = <<~VERSIONS + created_at: 2015-08-23T17:22:53-07:00 + --- + old_gem_one 1 e63fe62df0f1f459d2f70986f79745c6 + old_gem_two 0.1.0,0.1.1,0.1.2,0.2.0,0.2.1,0.3.0,0.4.0,0.4.1,0.5.0,0.5.1,0.5.2,0.5.3 c54e4b7e14861a5d8c225283b75075f4 + #{version.rubygem.name} #{version.number} #{info_checksum} + VERSIONS + + assert_equal content, RubygemFs.compact_index.get("versions") + + assert_equal( + { + metadata: { + "surrogate-control" => "max-age=3600, stale-while-revalidate=1800", + "surrogate-key" => "versions s3-compact-index s3-versions", + "sha256" => Digest::SHA256.base64digest(content), + "md5" => Digest::MD5.base64digest(content) + }, + cache_control: "max-age=60, public", + content_type: "text/plain; charset=utf-8", + checksum_sha256: Digest::SHA256.base64digest(content), + content_md5: Digest::MD5.base64digest(content), + key: "versions" + }, RubygemFs.compact_index.head("versions") + ) + + assert_enqueued_with(job: FastlyPurgeJob, args: [{ key: "s3-versions", soft: true }]) + end +end diff --git a/test/jobs/verify_link_job_test.rb b/test/jobs/verify_link_job_test.rb new file mode 100644 index 00000000000..121aeed0224 --- /dev/null +++ b/test/jobs/verify_link_job_test.rb @@ -0,0 +1,214 @@ +# frozen_string_literal: true + +require "test_helper" + +class VerifyLinkJobTest < ActiveJob::TestCase + LINKBACK_HTML = <<~HTML + + + Site with valid linkbacks + + + + + + + HTML + + NO_LINKBACK_HTML = <<~HTML + + + Site with invalid linkbacks + + + + notmygem + + + HTML + + GITHUB_HTML = <<~HTML + + + mygem on Github: a gem among gems + + + my github gem on rubygems.org + + + HTML + + GITHUB_PROFILE_HTML = <<~HTML + + + mygem on Github: a gem among gems + + + my github gem on rubygems.org + + + HTML + + setup do + @links = { + home: "https://example.com", + wiki: "https://example.com/no-linkback/", + mail: "https://github.com/rubygems", + docs: "http://example.com", + code: "https://github.com/rubygems/mygem", + bugs: "https://bad-url.com" + } + + @rubygem = create(:rubygem, name: "mygem", linkset: build(:linkset, home: nil)) + @links.each do |link, url| + LinkVerification.insert!({ linkable_id: @rubygem.id, linkable_type: "Rubygem", uri: url }) + instance_variable_set :"@#{link}", LinkVerification.find_by!(linkable: @rubygem, uri: url) + end + + assert_no_enqueued_jobs only: VerifyLinkJob + + stub_request(:get, @links[:home]) + .with( + headers: { + "Accept" => "text/html", + "Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", + "User-Agent" => "RubyGems.org Linkback Verification/#{AppRevision.version}" + } + ) + .to_return(status: 200, body: LINKBACK_HTML, headers: {}) + + stub_request(:get, @links[:docs]) + .with( + headers: { + "Accept" => "text/html", + "Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", + "User-Agent" => "RubyGems.org Linkback Verification/#{AppRevision.version}" + } + ) + .to_return(status: 200, body: LINKBACK_HTML, headers: {}) + + stub_request(:get, @links[:code]) + .with( + headers: { + "Accept" => "text/html", + "Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", + "User-Agent" => "RubyGems.org Linkback Verification/#{AppRevision.version}" + } + ) + .to_return(status: 200, body: GITHUB_HTML, headers: {}) + + stub_request(:get, @links[:mail]) + .with( + headers: { + "Accept" => "text/html", + "Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", + "User-Agent" => "RubyGems.org Linkback Verification/#{AppRevision.version}" + } + ) + .to_return(status: 200, body: GITHUB_PROFILE_HTML, headers: {}) + + stub_request(:get, @links[:wiki]) + .with( + headers: { + "Accept" => "text/html", + "Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", + "User-Agent" => "RubyGems.org Linkback Verification/#{AppRevision.version}" + } + ) + .to_return(status: 200, body: NO_LINKBACK_HTML, headers: {}) + + stub_request(:get, @links[:bugs]) + .to_return(status: 404, body: "", headers: {}) + end + + test "verifies link" do + freeze_time + + VerifyLinkJob.perform_now(link_verification: @home) + + assert_predicate @home.reload, :verified? + assert_equal Time.current, @home.last_verified_at + assert_equal 0, @home.failures_since_last_verification + assert_no_enqueued_jobs only: VerifyLinkJob + end + + test "verifies github repo link" do + freeze_time + + VerifyLinkJob.perform_now(link_verification: @code) + + assert_predicate @code.reload, :verified? + assert_equal Time.current, @code.last_verified_at + assert_equal 0, @code.failures_since_last_verification + assert_no_enqueued_jobs only: VerifyLinkJob + end + + test "verifies github profile link" do + freeze_time + + VerifyLinkJob.perform_now(link_verification: @mail) + + assert_predicate @mail.reload, :verified? + assert_equal Time.current, @mail.last_verified_at + assert_equal 0, @mail.failures_since_last_verification + assert_no_enqueued_jobs only: VerifyLinkJob + end + + test "does not verify link if not found" do + freeze_time + + VerifyLinkJob.perform_now(link_verification: @bugs) + + refute_predicate @bugs.reload, :verified? + assert_nil @bugs.last_verified_at + assert_equal 1, @bugs.failures_since_last_verification + assert_enqueued_jobs 1, only: VerifyLinkJob + end + + test "does not verify link for the wrong gem" do + @wiki.update_columns(linkable_id: create(:rubygem, name: "other-gem").id) + freeze_time + + VerifyLinkJob.perform_now(link_verification: @wiki) + + refute_predicate @wiki.reload, :verified? + assert_nil @wiki.last_verified_at + assert_equal 1, @wiki.failures_since_last_verification + assert_enqueued_jobs 1, only: VerifyLinkJob + end + + test "does not retry link verification after max failures" do + @bugs.update_columns(failures_since_last_verification: LinkVerification::MAX_FAILURES - 1) + freeze_time + + VerifyLinkJob.perform_now(link_verification: @bugs) + + refute_predicate @bugs.reload, :verified? + assert_nil @bugs.last_verified_at + assert_equal LinkVerification::MAX_FAILURES, @bugs.failures_since_last_verification + assert_enqueued_jobs 0, only: VerifyLinkJob + end + + test "does not retry link verification for http link" do + freeze_time + + VerifyLinkJob.perform_now(link_verification: @docs) + + refute_predicate @docs.reload, :verified? + assert_nil @docs.last_verified_at + assert_equal 1, @docs.failures_since_last_verification + assert_enqueued_jobs 0, only: VerifyLinkJob + end + + test "does not retry link verification for localhost link" do + @home.update!(uri: "https://localhost") + freeze_time + + VerifyLinkJob.perform_now(link_verification: @home) + + refute_predicate @home.reload, :verified? + assert_nil @home.last_verified_at + assert_equal 1, @home.failures_since_last_verification + assert_enqueued_jobs 0, only: VerifyLinkJob + end +end diff --git a/test/jobs/yank_version_contents_job_test.rb b/test/jobs/yank_version_contents_job_test.rb new file mode 100644 index 00000000000..49e324fa1be --- /dev/null +++ b/test/jobs/yank_version_contents_job_test.rb @@ -0,0 +1,154 @@ +require "test_helper" +require "magic" + +class YankVersionContentsJobTest < ActiveJob::TestCase + setup do + RubygemFs.mock! + + @user = create(:user) + @api_key = create(:api_key, owner: @user) + gem_file("bin_and_img-0.1.0.gem") { |gem| Pusher.new(@api_key, gem).process } + @version = Version.last + StoreVersionContentsJob.perform_now(version: @version) + @rubygem = @version.rubygem + @paths = @version.manifest.paths + @checksums = @version.manifest.checksums + + assert_predicate @paths, :any? + assert_predicate @checksums, :any? + + @version.update(indexed: false, yanked_at: Time.now.utc) + end + + teardown do + RubygemFs.mock! + end + + def perform(version: @version) + YankVersionContentsJob.perform_now(version:) + end + + def perform_retries(version: @version) + ActiveJob::Base.queue_adapter.perform_enqueued_at_jobs = true + perform(version:) + ensure + ActiveJob::Base.queue_adapter.perform_enqueued_at_jobs = false + end + + context "a not found version" do + should "raise on nil" do + assert_raises(YankVersionContentsJob::VersionNotYanked) do + perform_retries(version: nil) + end + end + end + + context "a unyanked gem" do + setup do + @version.update(indexed: true, yanked_at: nil) + end + + should "exit processing before doing anything" do + assert_raises(YankVersionContentsJob::VersionNotYanked) do + perform_retries + end + + assert_equal @paths, @version.manifest.paths, "indexed paths should be unchanged" + assert_equal @checksums, @version.manifest.checksums, "checksums should be unchanged" + end + end + + context "a yanked gem" do + should "remove paths for the version" do + perform + + assert_predicate @version.manifest.paths, :empty?, "indexed files should now be empty" + end + + context "with no stored contents (as if already yanked or never uploaded)" do + setup do + perform + end + + should "no-op gracefully" do + assert_nil @version.manifest.spec + assert_empty @version.manifest.paths + assert_empty @version.manifest.checksums + assert_empty @version.manifest.contents.keys + + perform + end + end + + context "only version" do + should "remove spec" do + perform + + assert_nil @version.manifest.spec + end + + should "remove all paths" do + perform + + assert_empty @version.manifest.paths + @paths.each do |path| + refute @version.manifest.entry(path), "file should not exist for #{path}" + end + end + + should "remove all checksums" do + perform + + assert_empty @version.manifest.checksums + end + + should "remove all stored contents" do + perform + + @checksums.each do |path, checksum| + refute @version.rubygem.file_content(checksum), "contents should not exist for #{path}" + end + end + end + + context "with other versions" do + setup do + @new_version = create(:version, rubygem: @rubygem, number: "0.1.1", indexed: true) + @new_paths = ["README.md", "LICENSE.txt", ".gitignore"] + new_entries = @new_paths.map { |path| @version.manifest.entry(path) } + @new_version.manifest.store_entries(new_entries) + @new_checksums = @new_version.manifest.checksums + end + + should "not remove shared files" do + perform + + assert_equal @new_paths.sort, @new_version.manifest.paths.sort + + @new_paths.each do |path| + assert @new_version.manifest.entry(path) + end + + @new_checksums.each do |path, checksum| + assert @rubygem.file_content(checksum), "contents should continue to exist for #{path}" + end + end + + should "remove only unique files" do + perform + + @paths.each do |path| + next if @new_paths.include?(path) + + refute @version.manifest.entry(path), "file by path should not exist for #{path}" + end + + @checksums.each do |path, checksum| + next if @new_checksums[path] + + refute @rubygem.file_content(checksum), "contents should not exist for #{path}" + end + end + end + end +end diff --git a/test/lib/access_test.rb b/test/lib/access_test.rb new file mode 100644 index 00000000000..1173c5f54f6 --- /dev/null +++ b/test/lib/access_test.rb @@ -0,0 +1,40 @@ +require "test_helper" + +class AccessTest < ActiveSupport::TestCase + context ".role_for_flag" do + should "return the role for a given permission flag" do + assert_equal "owner", Access.role_for_flag(Access::OWNER) + end + + context "when the permission flag does not exist" do + should "raise an error" do + assert_raises(ArgumentError) { Access.role_for_flag(999) } + end + end + end + + context ".flag_for_role" do + should "return the role for a given permission flag" do + assert_equal Access::OWNER, Access.flag_for_role("owner") + end + + should "cast the given input into the correct type" do + assert_equal Access::OWNER, Access.flag_for_role(:owner) + end + + context "when the role does not exist" do + should "raise an error" do + assert_raises(KeyError) { Access.flag_for_role("unknown") } + end + end + end + + context ".with_minimum_role" do + should "return the range of roles for a given permission flag" do + assert_equal Range.new(Access::OWNER, nil), Access.with_minimum_role("owner") + refute_includes Access.with_minimum_role("owner"), Access::MAINTAINER + assert_includes Access.with_minimum_role("owner"), Access::OWNER + assert_includes Access.with_minimum_role("owner"), Access::ADMIN + end + end +end diff --git a/test/mailers/mailer_test.rb b/test/mailers/mailer_test.rb new file mode 100644 index 00000000000..2aa1c920042 --- /dev/null +++ b/test/mailers/mailer_test.rb @@ -0,0 +1,17 @@ +require "test_helper" + +class MailerTest < ActionMailer::TestCase + setup do + @user = create(:user) + end + + context "#email_reset_update" do + should "include host in subject" do + email = Mailer.email_reset_update(@user) + + assert_emails(1) { email.deliver_now } + + assert_includes email.subject, Gemcutter::HOST_DISPLAY + end + end +end diff --git a/test/mailers/owners_test.rb b/test/mailers/owners_test.rb new file mode 100644 index 00000000000..a4bd094fd7d --- /dev/null +++ b/test/mailers/owners_test.rb @@ -0,0 +1,20 @@ +require "test_helper" + +class OwnersMailerTest < ActionMailer::TestCase + setup do + @owner = create(:user) + @maintainer = create(:user) + @rubygem = create(:rubygem, name: "test-gem") + @owner_ownership = create(:ownership, rubygem: @rubygem, user: @owner) + @maintainer_ownership = create(:ownership, rubygem: @rubygem, user: @maintainer) + end + + context "#owner_updated" do + should "include host in subject" do + email = OwnersMailer.with(ownership: @maintainer_ownership).owner_updated + + assert_emails(1) { email.deliver_now } + assert_equal email.subject, "Your role was updated for #{@rubygem.name} gem" + end + end +end diff --git a/test/mailers/password_mailer_test.rb b/test/mailers/password_mailer_test.rb new file mode 100644 index 00000000000..e923dd6c878 --- /dev/null +++ b/test/mailers/password_mailer_test.rb @@ -0,0 +1,29 @@ +require "test_helper" + +class PasswordMailerTest < ActionMailer::TestCase + test "change password with handle" do + user = create(:user) + email = PasswordMailer.change_password(user) + + assert_emails 1 do + email.deliver_now + end + + assert_equal I18n.t("clearance.models.clearance_mailer.change_password"), email.subject + assert_match user.handle, email.text_part.body.to_s + assert_match user.handle, email.html_part.body.to_s + end + + test "change password without handle should show email" do + user = create(:user, handle: nil) + email = PasswordMailer.change_password(user) + + assert_emails 1 do + email.deliver_now + end + + assert_equal I18n.t("clearance.models.clearance_mailer.change_password"), email.subject + assert_match user.email, email.text_part.body.to_s + assert_match user.email, email.html_part.body.to_s + end +end diff --git a/test/mailers/previews/mailer_preview.rb b/test/mailers/previews/mailer_preview.rb index 9b06cdf36d1..e2241ae7712 100644 --- a/test/mailers/previews/mailer_preview.rb +++ b/test/mailers/previews/mailer_preview.rb @@ -1,6 +1,6 @@ class MailerPreview < ActionMailer::Preview def email_reset - Mailer.email_reset(User.last) + Mailer.email_reset(User.first) end def email_reset_update @@ -12,7 +12,7 @@ def email_confirmation end def change_password - ClearanceMailer.change_password(User.last) + PasswordMailer.change_password(User.last) end def deletion_complete @@ -30,13 +30,38 @@ def notifiers_changed def gem_pushed ownership = Ownership.where.not(user: nil).where(push_notifier: true).last - Mailer.gem_pushed(ownership.user_id, ownership.rubygem.versions.last.id, ownership.user_id) + Mailer.gem_pushed(ownership.user, ownership.rubygem.versions.last.id, ownership.user_id) + end + + def gem_pushed_by_trusted_publisher + ownership = Ownership.where.not(user: nil).where(push_notifier: true).last + + Mailer.gem_pushed(OIDC::RubygemTrustedPublisher.first.trusted_publisher, ownership.rubygem.versions.last.id, ownership.user_id) + end + + def gem_trusted_publisher_added + rubygem_trusted_publisher = OIDC::RubygemTrustedPublisher.last + created_by_user = User.last + notified_user = User.first + Mailer.gem_trusted_publisher_added(rubygem_trusted_publisher, created_by_user, notified_user) end def mfa_notification Mailer.mfa_notification(User.last.id) end + def mfa_recommendation_announcement + Mailer.mfa_recommendation_announcement(User.last.id) + end + + def mfa_required_soon_announcement + Mailer.mfa_required_soon_announcement(User.last.id) + end + + def mfa_required_popular_gems_announcement + Mailer.mfa_required_popular_gems_announcement(User.last.id) + end + def gem_yanked ownership = Ownership.where.not(user: nil).last Mailer.gem_yanked(ownership.user.id, ownership.rubygem.versions.last.id, ownership.user.id) @@ -53,7 +78,7 @@ def honeycomb_reset_api_key end def ownership_confirmation - OwnersMailer.ownership_confirmation(Ownership.last.id) + OwnersMailer.ownership_confirmation(Ownership.last) end def owner_removed @@ -71,8 +96,140 @@ def owner_added OwnersMailer.owner_added(user.id, owner.id, authorizer.id, gem.id) end + def owner_updated + ownership = Ownership.last + owner = User.last + + OwnersMailer.with(ownership: ownership, authorizer: owner).owner_updated + end + def api_key_created - api_key = ApiKey.last + api_key = ApiKey.where(owner_type: "User").last Mailer.api_key_created(api_key.id) end + + def api_key_created_oidc_api_key_role + api_key = OIDC::IdToken.where.not(api_key_role: nil).last.api_key + Mailer.api_key_created(api_key.id) + end + + def api_key_revoked + api_key = ApiKey.where(owner_type: "User").last + Mailer.api_key_revoked(api_key.user.id, api_key.name, api_key.scopes.to_sentence, "https://example.com") + end + + def new_ownership_requests + gem = Rubygem.order(updated_at: :desc).last + user = gem.owners.last + OwnersMailer.new_ownership_requests(gem.id, user.id) + end + + def ownership_request_closed + ownership_request = OwnershipRequest.last + OwnersMailer.ownership_request_closed(ownership_request.id) + end + + def ownership_request_approved + ownership_request = OwnershipRequest.last + OwnersMailer.ownership_request_approved(ownership_request.id) + end + + def webhook_deleted_global + user = User.last + url = "https://example.com/webhook" + failure_count = 9999 + + WebHooksMailer.webhook_deleted(user.id, nil, url, failure_count) + end + + def webhook_deleted_single_gem + gem = Rubygem.order(updated_at: :desc).last + user = gem.owners.last + url = "https://example.com/webhook" + failure_count = 9999 + + WebHooksMailer.webhook_deleted(user.id, gem.id, url, failure_count) + end + + def webhook_disabled_global + web_hook = WebHook.new( + user: User.last, + last_failure: 2.minutes.ago, + last_success: 1.week.ago, + successes_since_last_failure: 0, + failures_since_last_success: 10, + failure_count: 200, + url: "https://example.com/webhook", + disabled_reason: WebHook::TOO_MANY_FAILURES_DISABLED_REASON + ) + + WebHooksMailer.webhook_disabled(web_hook) + end + + def webhook_disabled_single_gem + rubygem = Rubygem.order(updated_at: :desc).last + user = rubygem.owners.last + web_hook = WebHook.new( + rubygem:, + user:, + last_failure: 2.minutes.ago, + last_success: 1.week.ago, + successes_since_last_failure: 0, + failures_since_last_success: 10, + failure_count: 200, + url: "https://example.com/webhook", + disabled_reason: WebHook::TOO_MANY_FAILURES_DISABLED_REASON + ) + + WebHooksMailer.webhook_disabled(web_hook) + end + + def webauthn_credential_created + webauthn_credential = WebauthnCredential.last + + unless webauthn_credential + user_with_yubikey = User.create_with( + handle: "gem-user-with-yubikey", + password: "super-secret-password", + email_confirmed: true + ).find_or_create_by!(email: "gem-user-with-yubikey@example.com") + + webauthn_credential = user_with_yubikey.webauthn_credentials.create_with( + external_id: "external-id", + public_key: "public-key", + sign_count: 1 + ).find_or_create_by!(nickname: "Fake Yubikey") + end + + Mailer.webauthn_credential_created(webauthn_credential.id) + end + + def webauthn_credential_removed + user_id = User.last.id + webauthn_credential_nickname = "Fake Yubikey" + + Mailer.webauthn_credential_removed(user_id, webauthn_credential_nickname, Time.now.utc) + end + + def totp_enabled + user_id = User.last.id + + Mailer.totp_enabled(user_id, Time.now.utc) + end + + def totp_disabled + user_id = User.last.id + + Mailer.totp_disabled(user_id, Time.now.utc) + end + + def admin_manual + Mailer.admin_manual(User.last, "A subject", <<~TEXT) + A body + with multiple lines + and a link to https://example.com + and an emoji 🎉 + and a p tag

          with html

          and a link + TEXT + end end diff --git a/test/mailers/previews/mailer_preview_test.rb b/test/mailers/previews/mailer_preview_test.rb new file mode 100644 index 00000000000..04eba44fde0 --- /dev/null +++ b/test/mailers/previews/mailer_preview_test.rb @@ -0,0 +1,16 @@ +require "test_helper" +require_relative "mailer_preview" + +class MailerPreviewTest < ActiveSupport::TestCase + setup do + capture_io { Rails.application.load_seed } + end + + MailerPreview.emails.each do |email_name| + test email_name do + assert_nothing_raised do + MailerPreview.call(email_name) + end + end + end +end diff --git a/test/models/admin/github_user_test.rb b/test/models/admin/github_user_test.rb new file mode 100644 index 00000000000..970177795a1 --- /dev/null +++ b/test/models/admin/github_user_test.rb @@ -0,0 +1,8 @@ +require "test_helper" + +class Admin::GitHubUserTest < ActiveSupport::TestCase + should validate_presence_of :login + should validate_presence_of :github_id + should validate_uniqueness_of :github_id + should validate_presence_of :info_data +end diff --git a/test/models/api_key_rubygems_scope_test.rb b/test/models/api_key_rubygems_scope_test.rb new file mode 100644 index 00000000000..62efeb3c1e7 --- /dev/null +++ b/test/models/api_key_rubygems_scope_test.rb @@ -0,0 +1,40 @@ +require "test_helper" + +class ApiKeyRubygemScopeTest < ActiveSupport::TestCase + should belong_to :api_key + should belong_to :ownership + should validate_presence_of(:ownership) + should validate_presence_of(:api_key) + should validate_uniqueness_of(:ownership_id).scoped_to(:api_key_id) + + setup do + @api_key = create(:api_key) + @rubygem = create(:rubygem) + @ownership = create(:ownership, rubygem: @rubygem) + @api_key_scope = create(:api_key_rubygem_scope, api_key: @api_key, ownership: @ownership) + end + + should "be valid with factory" do + assert_predicate build(:api_key_rubygem_scope), :valid? + end + + context "#soft_delete_api_key!" do + should "be called if destroyed by association" do + @ownership.destroy! + + assert_nil @api_key.reload.api_key_rubygem_scope + assert_predicate @api_key, :soft_deleted? + assert_predicate @api_key, :soft_deleted_by_ownership? + assert_equal @rubygem.name, @api_key.reload.soft_deleted_rubygem_name + end + + should "call #soft_delete_api_key! if not destroyed by association" do + @api_key.update(ownership: nil) + + assert_nil @api_key.reload.api_key_rubygem_scope + refute_predicate @api_key, :soft_deleted? + refute_predicate @api_key, :soft_deleted_by_ownership? + assert_nil @api_key.reload.soft_deleted_rubygem_name + end + end +end diff --git a/test/models/api_key_test.rb b/test/models/api_key_test.rb new file mode 100644 index 00000000000..bd3a3993280 --- /dev/null +++ b/test/models/api_key_test.rb @@ -0,0 +1,331 @@ +require "test_helper" + +class ApiKeyTest < ActiveSupport::TestCase + should belong_to :owner + should validate_presence_of(:name) + should validate_presence_of(:hashed_key) + should have_one(:api_key_rubygem_scope).dependent(:destroy) + + should "be valid with factory" do + assert_predicate build(:api_key), :valid? + end + + should "set owner to user by default" do + api_key = create(:api_key) + + assert_equal api_key.user, api_key.owner + end + + should "be invalid when name is empty string" do + api_key = build(:api_key, name: "") + + refute_predicate api_key, :valid? + assert_contains api_key.errors[:name], "can't be blank" + end + + should "be invalid when name is longer than Gemcutter::MAX_FIELD_LENGTH" do + api_key = build(:api_key, name: "aa" * Gemcutter::MAX_FIELD_LENGTH) + + refute_predicate api_key, :valid? + assert_contains api_key.errors[:name], "is too long (maximum is 255 characters)" + end + + context "#scope" do + setup do + @api_key = create(:api_key, scopes: %i[index_rubygems push_rubygem]) + end + + should "return enabled scopes" do + assert_equal %i[index_rubygems push_rubygem], @api_key.scopes + end + end + + context "show_dashboard scope" do + should "be valid when enabled exclusively" do + assert_predicate build(:api_key, scopes: %i[show_dashboard]), :valid? + end + + should "be invalid when enabled with any other scope" do + refute_predicate build(:api_key, scopes: %i[show_dashboard push_rubygem]), :valid? + end + end + + context "gem scope" do + setup do + @ownership = create(:ownership) + @api_key = create(:api_key, scopes: %w[push_rubygem], owner: @ownership.user, ownership: @ownership) + @api_key_no_gem_scope = create(:api_key, key: SecureRandom.hex(24), scopes: %i[index_rubygems], owner: @ownership.user) + end + + should "be invalid if non applicable API scope is enabled" do + api_key = build(:api_key, scopes: %w[index_rubygems], owner: @ownership.user, ownership: @ownership) + + refute_predicate api_key, :valid? + assert_contains api_key.errors[:rubygem], "scope can only be set for push/yank rubygem, and add/remove owner scopes" + end + + should "be valid if applicable API scope is enabled" do + %i[push_rubygem yank_rubygem add_owner remove_owner].each do |scope| + api_key = build(:api_key, scopes: [scope], owner: @ownership.user, ownership: @ownership) + + assert_predicate api_key, :valid? + end + end + + context "#rubygem" do + should "return scoped rubygem when present" do + assert_equal @ownership.rubygem, @api_key.rubygem + end + + should "return nil when scope is not defined" do + assert_nil @api_key_no_gem_scope.rubygem + end + end + + context "#rubygem_id" do + should "return scoped rubygem id when present" do + assert_equal @ownership.rubygem_id, @api_key.rubygem_id + end + + should "return nil when scope is not defined" do + assert_nil @api_key_no_gem_scope.rubygem_id + end + end + + context "#rubygem_id=" do + should "set ownership to a gem" do + api_key = create(:api_key, key: SecureRandom.hex(24), scopes: %i[push_rubygem], owner: @ownership.user, rubygem_id: @ownership.rubygem_id) + + assert_equal @ownership.rubygem_id, api_key.rubygem_id + end + + should "set ownership to nil when id is nil" do + @api_key.rubygem_id = nil + + assert_nil @api_key.rubygem_id + end + + should "add error when id is not associated with the user" do + api_key = ApiKey.new(hashed_key: SecureRandom.hex(24), scopes: %i[push_rubygem], owner: @ownership.user, rubygem_id: -1) + + assert_contains api_key.errors[:rubygem], "must be a gem that you are an owner of" + end + end + + context "#rubygem_name=" do + should "set ownership to a gem" do + api_key = create( + :api_key, + key: SecureRandom.hex(24), + scopes: %i[push_rubygem], + owner: @ownership.user, + rubygem_name: @ownership.rubygem.name + ) + + assert_equal @ownership.rubygem, api_key.rubygem + end + + should "set ownership to nil when name is blank" do + @api_key.rubygem_name = nil + + assert_nil @api_key.ownership + end + + should "add error when gem is not associated with the user" do + rubygem = create(:rubygem, name: "another-gem") + api_key = ApiKey.new( + hashed_key: SecureRandom.hex(24), + scopes: %i[push_rubygem], + owner: @ownership.user, + rubygem_name: rubygem.name + ) + + assert_contains api_key.errors[:rubygem], "must be a gem that you are an owner of" + end + + should "add error when name is not a valid gem name" do + api_key = ApiKey.new( + hashed_key: SecureRandom.hex(24), + scopes: %i[push_rubygem], + owner: @ownership.user, + rubygem_name: "invalid-gem-name" + ) + + assert_contains api_key.errors[:rubygem], "could not be found" + end + end + end + + context "#soft_deleted?" do + should "return true if soft_deleted_at is set" do + api_key = create(:api_key) + api_key.soft_deleted_at = Time.now.utc + + assert_predicate api_key, :soft_deleted? + end + + should "return false if soft_deleted_at is not set" do + refute_predicate create(:api_key), :soft_deleted? + end + end + + context "#soft_delete!" do + should "set soft_deleted_at" do + api_key = create(:api_key) + + freeze_time do + api_key.soft_delete! + + assert_equal Time.now.utc, api_key.soft_deleted_at + end + end + end + + context "#soft_deleted_by_ownership?" do + should "return true if soft deleted gem name is present" do + ownership = create(:ownership) + api_key = create(:api_key, scopes: %i[push_rubygem], owner: ownership.user, ownership: ownership) + api_key.soft_delete!(ownership: ownership) + + assert_predicate api_key, :soft_deleted_by_ownership? + end + + should "return false if key not soft deleted" do + api_key = create(:api_key) + + refute_predicate api_key, :soft_deleted_by_ownership? + end + end + + should "be invalid if soft deleted" do + api_key = create(:api_key) + api_key.soft_delete! + + refute_predicate api_key, :valid? + assert_contains api_key.errors[:base], "An invalid API key cannot be used. Please delete it and create a new one." + end + + should "be invalid if expired" do + api_key = create(:api_key, expires_at: 10.minutes.from_now) + + travel 20.minutes + + refute_predicate api_key, :valid? + assert_contains api_key.errors[:base], "An expired API key cannot be used. Please create a new one." + end + + context "#mfa_authorized?" do + setup do + @api_key = create(:api_key) + end + + should "return true if mfa not enabled for api key" do + @api_key.user.enable_totp!(ROTP::Base32.random_base32, :ui_and_gem_signin) + + assert @api_key.mfa_authorized?(nil) + end + + context "with totp" do + should "return true when correct and mfa enabled" do + @api_key.user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + + assert @api_key.mfa_authorized?(ROTP::TOTP.new(@api_key.user.totp_seed).now) + end + + should "return false when incorrect and mfa enabled" do + @api_key.user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + + refute @api_key.mfa_authorized?(ROTP::TOTP.new(ROTP::Base32.random_base32).now) + end + end + + context "with webauthn otp" do + should "return true when correct and mfa enabled" do + @api_key.user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + webauthn_verification = create(:webauthn_verification, user: @api_key.user) + + assert @api_key.mfa_authorized?(webauthn_verification.otp) + end + + should "return false when incorrect and mfa enabled" do + @api_key.user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + create(:webauthn_verification, user: @api_key.user, otp: "jiEm2mm2sJtRqAVx7U1i") + incorrect_otp = "Yxf57d1wEUSWyXrrLMRv" + + refute @api_key.mfa_authorized?(incorrect_otp) + end + end + + context "with oidc id token" do + setup do + create(:oidc_id_token, api_key: @api_key) + end + + should "return true if mfa not enabled for api key" do + @api_key.user.enable_totp!(ROTP::Base32.random_base32, :ui_and_gem_signin) + + assert @api_key.mfa_authorized?(nil) + end + + should "return true if mfa enabled for api" do + @api_key.user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + + assert @api_key.mfa_authorized?(nil) + end + + should "return true if mfa enabled for api key" do + @api_key.update!(mfa: true) + + assert @api_key.mfa_authorized?(nil) + end + end + end + + context "#mfa_enabled?" do + setup do + @api_key = create(:api_key, scopes: %i[index_rubygems]) + end + + should "return false with MFA disabled user" do + refute_predicate @api_key, :mfa_enabled? + + @api_key.update(mfa: true) + + refute_predicate @api_key, :mfa_enabled? + end + + should "return mfa with MFA UI enabled user" do + @api_key.user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + + refute_predicate @api_key, :mfa_enabled? + + @api_key.update(mfa: true) + + assert_predicate @api_key, :mfa_enabled? + end + + should "return true with MFA UI and API enabled user" do + @api_key.user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + + assert_predicate @api_key, :mfa_enabled? + + @api_key.update(mfa: true) + + assert_predicate @api_key, :mfa_enabled? + end + + should "return false with MFA UI and API enabled user & short duration token" do + @api_key.user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + + [false, true].each do |mfa| + @api_key.update(mfa: mfa, expires_at: @api_key.created_at + 14.minutes) + + refute_predicate @api_key, :mfa_enabled? + + @api_key.update(mfa: mfa, expires_at: @api_key.created_at + 15.minutes) + + assert_predicate @api_key, :mfa_enabled? + end + end + end +end diff --git a/test/models/application_model_test.rb b/test/models/application_model_test.rb new file mode 100644 index 00000000000..2694eaaa9e4 --- /dev/null +++ b/test/models/application_model_test.rb @@ -0,0 +1,41 @@ +require "test_helper" + +class ApplicationModelTest < ActiveSupport::TestCase + make_my_diffs_pretty! + + context "with an uninitialized object" do + setup do + @model = ApplicationModel.allocate + end + + should "inspect as not initializee" do + assert_match " not initialized", @model.inspect + end + + should "pretty_inspect as not initializee" do + assert_match " not initialized", @model.pretty_inspect + end + end + + context "with no attributes" do + setup do + @model = ApplicationModel.new({}) + end + + should "inspect" do + assert_equal "#", @model.inspect + end + + should "pretty_inspect" do + assert_match(/#/, @model.pretty_inspect) + end + + should "compare equal to itself" do + assert_equal @model, @model.itself + end + + should "compare equal to a copy" do + assert_equal @model, ApplicationModel.new({}) + end + end +end diff --git a/test/models/audit_test.rb b/test/models/audit_test.rb new file mode 100644 index 00000000000..d09fc815fbd --- /dev/null +++ b/test/models/audit_test.rb @@ -0,0 +1,8 @@ +require "test_helper" + +class AuditTest < ActiveSupport::TestCase + should belong_to(:auditable) + should belong_to(:admin_github_user) + + should validate_presence_of(:action) +end diff --git a/test/unit/rubygem_searchable_test.rb b/test/models/concerns/rubygem_searchable_test.rb similarity index 74% rename from test/unit/rubygem_searchable_test.rb rename to test/models/concerns/rubygem_searchable_test.rb index c41fb7232b9..d7089196e78 100644 --- a/test/unit/rubygem_searchable_test.rb +++ b/test/models/concerns/rubygem_searchable_test.rb @@ -1,17 +1,17 @@ require "test_helper" class RubygemSearchableTest < ActiveSupport::TestCase - include ESHelper + include SearchKickHelper setup do - Rubygem.__elasticsearch__.create_index! force: true + Rubygem.searchkick_index.delete if Rubygem.searchkick_index.exists? end - context "#as_indexed_json" do + context "#search_data" do setup do @rubygem = create(:rubygem, name: "example_gem", downloads: 10) create(:version, number: "1.0.0", rubygem: @rubygem) - create(:version, + version = create(:version, number: "1.0.1", rubygem: @rubygem, summary: "some summary", @@ -25,15 +25,20 @@ class RubygemSearchableTest < ActiveSupport::TestCase "funding_uri" => "http://example.com", "documentation_uri" => "http://example.com" }) + dev_gem = create(:rubygem, name: "example_gem_dev_dep") + run_gem = create(:rubygem, name: "example_gem_run_dep") + @dev_dep = create(:dependency, :development, rubygem: dev_gem, version: version) + @run_dep = create(:dependency, :runtime, rubygem: run_gem, version: version) end should "return a hash" do - json = @rubygem.as_indexed_json + json = @rubygem.search_data + assert_equal json.class, Hash end should "set values from most recent versions" do - json = @rubygem.as_indexed_json + json = @rubygem.search_data expected_hash = { name: "example_gem", @@ -67,13 +72,55 @@ class RubygemSearchableTest < ActiveSupport::TestCase summary: "some summary", description: "some description", updated: @rubygem.updated_at, - dependencies: { development: [], runtime: [] } + dependencies: { development: [@dev_dep], runtime: [@run_dep] } } expected_hash.each do |k, v| assert_equal v, json[k], "value doesn't match for key: #{k}" end end + + should "set the suggest json" do + json = @rubygem.search_data + + assert_equal "example_gem", json[:suggest][:input] + end + + should "calculate the suggestion weight based on the number of downloads" do + weights = [ + [0, 0], + [10, 1], + [100, 2], + [1_000, 3], + [10_000, 4], + [100_000, 5], + [1_000_000, 6], + [10_000_000, 7], + [100_000_000, 8], + [1_000_000_000, 9] + ] + + weights.each do |downloads, weight| + @rubygem.gem_download.update(count: downloads) + json = @rubygem.search_data + + assert_equal weight, json[:suggest][:weight] + end + end + + context "when the number of downloads exceeds a 32 bit integer" do + setup do + @rubygem = create(:rubygem, name: "large_downloads_example_gem", downloads: 10_000_000_000) # 10 Billion downloads + @version = create(:version, number: "1.0.0", rubygem: @rubygem) + import_and_refresh + end + + should "allow the number of downloads to be stored as a 64 bit integer" do + json = @rubygem.search_data + + assert_equal 10_000_000_000, json[:downloads] + end + end end context "rubygems analyzer" do @@ -86,8 +133,10 @@ class RubygemSearchableTest < ActiveSupport::TestCase should "find all gems with matching tokens" do _, response = ElasticSearcher.new("example").search + assert_equal 3, response.size results = %w[example-gem example_1 example.rb] + assert_equal results, response.map(&:name) end end @@ -103,6 +152,7 @@ class RubygemSearchableTest < ActiveSupport::TestCase should "filter yanked gems from the result" do _, response = ElasticSearcher.new("example").search + assert_equal 1, response.size assert_equal "example_2", response.first.name end @@ -123,6 +173,7 @@ class RubygemSearchableTest < ActiveSupport::TestCase should "look for keyword in name, summary and description and order them in same priority order" do _, response = ElasticSearcher.new("keyword").search names_order = %w[keyword example_gem3 example_gem2] + assert_equal names_order, response.results.map(&:name) end end @@ -139,6 +190,7 @@ class RubygemSearchableTest < ActiveSupport::TestCase should "boost score of result by downloads count" do _, response = ElasticSearcher.new("gem").search names_order = %w[gem_30 gem_20 gem_10] + assert_equal names_order, response.results.map(&:name) end end @@ -152,15 +204,17 @@ class RubygemSearchableTest < ActiveSupport::TestCase should "return all terms of source" do _, response = ElasticSearcher.new("example_gem").search + result = response.response["hits"]["hits"].first["_source"] + hash = { - name: "example_gem", - downloads: 10, - summary: "some summary", - description: "some description" + "name" => "example_gem", + "downloads" => 10, + "summary" => "some summary", + "description" => "some description" } hash.each do |k, v| - assert_equal v, response.results.first._source[k] + assert_equal v, result[k] end end end @@ -177,7 +231,15 @@ class RubygemSearchableTest < ActiveSupport::TestCase should "suggest names of possible gems" do _, response = ElasticSearcher.new("keywor").search suggestions = %w[keyword keywo keywordo] - assert_equal suggestions, response.suggestions.terms + + assert_equal suggestions, response.suggestions + end + + should "return names of suggestion gems" do + response = ElasticSearcher.new("keywor").suggestions + suggestions = %w[keyword keywordo] + + assert_equal suggestions, %W[#{response[0]} #{response[1]}] end end @@ -192,36 +254,42 @@ class RubygemSearchableTest < ActiveSupport::TestCase should "filter gems on downloads" do _, response = ElasticSearcher.new("downloads:>100").search + assert_equal 1, response.size assert_equal "example", response.first.name end should "filter gems on name" do _, response = ElasticSearcher.new("name:web-rubygem").search + assert_equal 1, response.size assert_equal "web-rubygem", response.first.name end should "filter gems on summary" do _, response = ElasticSearcher.new("summary:special word").search + assert_equal 1, response.size assert_equal "example", response.first.name end should "filter gems on description" do _, response = ElasticSearcher.new("description:example").search + assert_equal 1, response.size assert_equal "web-rubygem", response.first.name end should "change default operator" do _, response = ElasticSearcher.new("example OR web-rubygem").search + assert_equal 2, response.size assert_equal %w[web-rubygem example], response.map(&:name) end should "support wildcards" do _, response = ElasticSearcher.new("name:web*").search + assert_equal 1, response.size assert_equal "web-rubygem", response.first.name end @@ -241,13 +309,16 @@ class RubygemSearchableTest < ActiveSupport::TestCase should "aggregate matched fields" do buckets = @response.response["aggregations"]["matched_field"]["buckets"] + assert_equal 1, buckets["name"]["doc_count"] assert_equal 0, buckets["summary"]["doc_count"] assert_equal 1, buckets["description"]["doc_count"] end + # NOTE: this can fail when OpenSearch date is different to Ruby date should "aggregate date range" do buckets = @response.response["aggregations"]["date_range"]["buckets"] + assert_equal 2, buckets[0]["doc_count"] assert_equal 1, buckets[1]["doc_count"] end @@ -257,37 +328,30 @@ class RubygemSearchableTest < ActiveSupport::TestCase context "exception handling" do setup { import_and_refresh } - context "Elasticsearch::Transport::Transport::Errors::BadRequest" do + context "Searchkick::InvalidQueryError" do setup do @ill_formated_query = "updated:[2016-08-10 TO }" end - should "fallback to legacy search" do - Rubygem.expects(:legacy_search).with(@ill_formated_query).returns(Rubygem.all) - - ElasticSearcher.new(@ill_formated_query).search - end - should "give correct error message" do - expected_msg = "Failed to parse: '#{@ill_formated_query}'. Falling back to legacy search." + expected_msg = "Failed to parse search term: '#{@ill_formated_query}'." - @error_msg, = ElasticSearcher.new(@ill_formated_query).search + @error_msg, result = ElasticSearcher.new(@ill_formated_query).search + assert_nil result assert_equal expected_msg, @error_msg end end - context "Elasticsearch::Transport::Transport::Errors" do - setup do - Rubygem.expects(:legacy_search).with("something").returns(Rubygem.all) - end - - should "fallback to legacy search and give correct error message" do + context "OpenSearch::Transport::Transport::Errors" do + should "fails with friendly error message" do requires_toxiproxy Toxiproxy[:elasticsearch].down do - error_msg, = ElasticSearcher.new("something").search - expected_msg = "Advanced search is currently unavailable. Falling back to legacy search." + error_msg, result = ElasticSearcher.new("something").search + expected_msg = "Search is currently unavailable. Please try again later." + + assert_nil result assert_equal expected_msg, error_msg end end @@ -306,6 +370,7 @@ class RubygemSearchableTest < ActiveSupport::TestCase should "not affect results" do _, response1 = ElasticSearcher.new("async rails").search _, response2 = ElasticSearcher.new("rails async").search + assert_equal response1.results.map(&:name), response2.results.map(&:name) end end @@ -326,4 +391,18 @@ class RubygemSearchableTest < ActiveSupport::TestCase assert_equal "term-ansicolor", response.first.name end end + + # ensure search indexing doesn't block storing of model + # changes since ES can be down and indexing is done in async way + context "automated indexing" do + should "be disabled" do + requires_toxiproxy + + Toxiproxy[:elasticsearch].down do + rubygem = create(:rubygem, name: "common-gem", number: "0.0.1", downloads: 10) + + assert rubygem.update(name: "renamed-gem") + end + end + end end diff --git a/test/models/concerns/user_multifactor_methods_test.rb b/test/models/concerns/user_multifactor_methods_test.rb new file mode 100644 index 00000000000..52318bb0b35 --- /dev/null +++ b/test/models/concerns/user_multifactor_methods_test.rb @@ -0,0 +1,513 @@ +require "test_helper" + +class UserMultifactorMethodsTest < ActiveSupport::TestCase + include ActionMailer::TestHelper + + setup do + @user = create(:user) + end + + context "validation" do + context "#mfa_level_for_enabled_devices" do + context "when mfa_level is disabled" do + should "be valid if there no mfa devices" do + assert_predicate @user, :valid? + assert_predicate @user, :no_mfa_devices? + end + + should "be invalid if totp is enabled" do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + @user.mfa_level = :disabled + + refute_predicate @user, :valid? + end + + should "be invalid if webauthn is enabled" do + create(:webauthn_credential, user: @user) + @user.mfa_level = :disabled + + refute_predicate @user, :valid? + end + + should "be invalid if both totp and webauthn are enabled" do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + create(:webauthn_credential, user: @user) + @user.mfa_level = :disabled + + refute_predicate @user, :valid? + end + end + + context "when mfa_level is ui_and_gem_signin" do + should "be invalid if there no mfa devices" do + @user.mfa_level = :ui_and_gem_signin + + assert_predicate @user, :no_mfa_devices? + refute_predicate @user, :valid? + end + + should "be valid if totp is enabled" do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + @user.mfa_level = :ui_and_gem_signin + + assert_predicate @user, :valid? + end + + should "be valid if webauthn is enabled" do + create(:webauthn_credential, user: @user) + @user.mfa_level = :ui_and_gem_signin + + assert_predicate @user, :valid? + end + + should "be valid if both totp and webauthn are enabled" do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + create(:webauthn_credential, user: @user) + @user.mfa_level = :ui_and_gem_signin + + assert_predicate @user, :valid? + end + end + + context "when mfa_level is ui_and_api" do + should "be invalid if there no mfa devices" do + @user.mfa_level = :ui_and_api + + assert_predicate @user, :no_mfa_devices? + refute_predicate @user, :valid? + end + + should "be valid if totp is enabled" do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + @user.mfa_level = :ui_and_api + + assert_predicate @user, :valid? + end + + should "be valid if webauthn is enabled" do + create(:webauthn_credential, user: @user) + @user.mfa_level = :ui_and_api + + assert_predicate @user, :valid? + end + + should "be valid if both totp and webauthn are enabled" do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + create(:webauthn_credential, user: @user) + @user.mfa_level = :ui_and_api + + assert_predicate @user, :valid? + end + end + end + end + + context "#mfa_enabled" do + should "return true if multifactor auth is not disabled using totp" do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + + assert_predicate @user, :mfa_enabled? + end + + should "return false if multifactor auth is disabled using totp" do + @user.disable_totp! + + refute_predicate @user, :mfa_enabled? + end + + should "return true if multifactor auth is not disabled using WebAuthn" do + create(:webauthn_credential, user: @user) + + assert_predicate @user, :mfa_enabled? + end + + should "return true if multifactor auth is disabled using WebAuthn" do + create(:webauthn_credential, user: @user) + @user.webauthn_credentials.first.destroy + + refute_predicate @user, :mfa_enabled? + end + + should "send totp enabled email" do + assert_emails 1 do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_gem_signin) + end + + assert_equal "Authentication app enabled on RubyGems.org", last_email.subject + assert_equal [@user.email], last_email.to + end + end + + context "#mfa_device_count_one?" do + should "return true if user has one webauthn credential and no totp" do + create(:webauthn_credential, user: @user) + + assert_predicate @user, :mfa_device_count_one? + end + + should "return true if user has totp enabled and no webauthn credential" do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + + assert_predicate @user, :mfa_device_count_one? + end + + should "return false if user has totp enabled and one webauthn credential" do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + create(:webauthn_credential, user: @user) + + refute_predicate @user, :mfa_device_count_one? + end + + should "return false if user has no totp and no webauthn credential" do + refute_predicate @user, :mfa_device_count_one? + end + + should "return false if user has two webauthn credentials" do + create(:webauthn_credential, user: @user) + create(:webauthn_credential, user: @user) + + refute_predicate @user, :mfa_device_count_one? + end + end + + context "#no_mfa_devices?" do + should "return true if user has no totp and no webauthn credential" do + assert_predicate @user, :no_mfa_devices? + end + + should "return false if user has one webauthn credential" do + create(:webauthn_credential, user: @user) + + refute_predicate @user, :no_mfa_devices? + end + + should "return false if user has totp enabled" do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + + refute_predicate @user, :no_mfa_devices? + end + + should "return false if user has totp and webauthn enabled" do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + create(:webauthn_credential, user: @user) + + refute_predicate @user, :no_mfa_devices? + end + end + + context "#mfa_gem_signin_authorized?" do + setup do + @seed = ROTP::Base32.random_base32 + end + + context "with totp" do + should "return true when correct and if mfa is ui_and_api" do + @user.enable_totp!(@seed, :ui_and_api) + + assert @user.mfa_gem_signin_authorized?(ROTP::TOTP.new(@seed).now) + end + + should "return true when correct and if mfa is ui_and_gem_signin" do + @user.enable_totp!(@seed, :ui_and_gem_signin) + + assert @user.mfa_gem_signin_authorized?(ROTP::TOTP.new(@seed).now) + end + + should "return false when incorrect" do + @user.enable_totp!(@seed, :ui_and_gem_signin) + + refute @user.mfa_gem_signin_authorized?(ROTP::TOTP.new(ROTP::Base32.random_base32).now) + end + end + + context "with webauthn otp" do + should "return true when correct and if mfa is ui_and_api" do + @user.enable_totp!(@seed, :ui_and_api) + webauthn_verification = create(:webauthn_verification, user: @user) + + assert @user.mfa_gem_signin_authorized?(webauthn_verification.otp) + end + + should "return true when correct and if mfa is ui_and_gem_signin" do + @user.enable_totp!(@seed, :ui_and_gem_signin) + webauthn_verification = create(:webauthn_verification, user: @user) + + assert @user.mfa_gem_signin_authorized?(webauthn_verification.otp) + end + + should "return true when correct and if mfa is disabled" do + webauthn_verification = create(:webauthn_verification, user: @user) + + assert @user.mfa_gem_signin_authorized?(webauthn_verification.otp) + end + + should "return false when incorrect" do + @user.enable_totp!(@seed, :ui_and_gem_signin) + create(:webauthn_verification, user: @user, otp: "jiEm2mm2sJtRqAVx7U1i") + incorrect_otp = "Yxf57d1wEUSWyXrrLMRv" + + refute @user.mfa_gem_signin_authorized?(incorrect_otp) + end + end + + should "return true if mfa is disabled" do + assert @user.mfa_gem_signin_authorized?(ROTP::TOTP.new(@seed).now) + end + + should "return true if mfa is ui_only" do + @user.enable_totp!(@seed, :ui_only) + + assert @user.mfa_gem_signin_authorized?(ROTP::TOTP.new(@seed).now) + end + end + + context "#mfa_recommended_not_yet_enabled?" do + setup do + @popular_rubygem = create(:rubygem) + GemDownload.increment( + Rubygem::MFA_RECOMMENDED_THRESHOLD + 1, + rubygem_id: @popular_rubygem.id + ) + end + + should "return true if instance owns a gem that exceeds recommended threshold and has mfa disabled" do + create(:ownership, user: @user, rubygem: @popular_rubygem) + + assert_predicate @user, :mfa_recommended_not_yet_enabled? + end + + should "return false if instance owns a gem that exceeds recommended threshold and has mfa enabled" do + create(:ownership, user: @user, rubygem: @popular_rubygem) + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + + refute_predicate @user, :mfa_recommended_not_yet_enabled? + end + + should "return false if instance does not own a gem that exceeds recommended threshold and has mfa disabled" do + create(:ownership, user: @user, rubygem: create(:rubygem)) + + refute_predicate @user, :mfa_recommended_not_yet_enabled? + end + end + + context "#mfa_recommended_weak_level_enabled?" do + setup do + @popular_rubygem = create(:rubygem) + GemDownload.increment( + Rubygem::MFA_RECOMMENDED_THRESHOLD + 1, + rubygem_id: @popular_rubygem.id + ) + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + end + + should "return true if instance owns a gem that exceeds recommended threshold and has mfa ui_only" do + create(:ownership, user: @user, rubygem: @popular_rubygem) + + assert_predicate @user, :mfa_recommended_weak_level_enabled? + end + + should "return false if instance owns a gem that exceeds recommended threshold and has mfa disabled" do + create(:ownership, user: @user, rubygem: @popular_rubygem) + @user.disable_totp! + + refute_predicate @user, :mfa_recommended_weak_level_enabled? + end + + should "return false if instance does not own a gem that exceeds recommended threshold and has mfa disabled" do + create(:ownership, user: @user, rubygem: create(:rubygem)) + + refute_predicate @user, :mfa_recommended_weak_level_enabled? + end + end + + context "#mfa_required_not_yet_enabled?" do + setup do + @popular_rubygem = create(:rubygem) + GemDownload.increment( + Rubygem::MFA_REQUIRED_THRESHOLD + 1, + rubygem_id: @popular_rubygem.id + ) + end + + should "return true if instance owns a gem that exceeds required threshold and has mfa disabled" do + create(:ownership, user: @user, rubygem: @popular_rubygem) + + assert_predicate @user, :mfa_required_not_yet_enabled? + end + + should "return false if instance owns a gem that exceeds required threshold and has mfa enabled" do + create(:ownership, user: @user, rubygem: @popular_rubygem) + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + + refute_predicate @user, :mfa_required_not_yet_enabled? + end + + should "return false if instance does not own a gem that exceeds required threshold and has mfa disabled" do + create(:ownership, user: @user, rubygem: create(:rubygem)) + + refute_predicate @user, :mfa_required_not_yet_enabled? + end + end + + context "#mfa_required_weak_level_enabled?" do + setup do + @popular_rubygem = create(:rubygem) + GemDownload.increment( + Rubygem::MFA_REQUIRED_THRESHOLD + 1, + rubygem_id: @popular_rubygem.id + ) + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + end + + should "return true if instance owns a gem that exceeds required threshold and has mfa ui_only" do + create(:ownership, user: @user, rubygem: @popular_rubygem) + + assert_predicate @user, :mfa_required_weak_level_enabled? + end + + should "return false if instance owns a gem that exceeds required threshold and has mfa disabled" do + create(:ownership, user: @user, rubygem: @popular_rubygem) + @user.disable_totp! + + refute_predicate @user, :mfa_required_weak_level_enabled? + end + + should "return false if instance does not own a gem that exceeds required threshold and has mfa disabled" do + create(:ownership, user: @user, rubygem: create(:rubygem)) + + refute_predicate @user, :mfa_required_weak_level_enabled? + end + end + + context "#ui_mfa_verified?" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + end + + context "with totp" do + should "return true when correct" do + assert @user.ui_mfa_verified?(ROTP::TOTP.new(@user.totp_seed).now) + end + + should "return true when correct in last interval" do + last_otp = ROTP::TOTP.new(@user.totp_seed).at(Time.current - 30) + + assert @user.ui_mfa_verified?(last_otp) + end + + should "return true when correct in next interval" do + next_otp = ROTP::TOTP.new(@user.totp_seed).at(Time.current + 30) + + assert @user.ui_mfa_verified?(next_otp) + end + + should "return false when incorrect" do + refute @user.ui_mfa_verified?(ROTP::TOTP.new(ROTP::Base32.random_base32).now) + end + + should "return false if the mfa_seed is blank" do + @user.disable_totp! + + refute @user.ui_mfa_verified?(ROTP::TOTP.new(ROTP::Base32.random_base32).now) + end + end + + context "with webauthn otp" do + should "return false" do + webauthn_verification = create(:webauthn_verification, user: @user) + + refute @user.ui_mfa_verified?(webauthn_verification.otp) + end + end + + should "return true if recovery code is correct" do + recovery_code = @user.new_mfa_recovery_codes.first + + assert @user.ui_mfa_verified?(recovery_code) + refute(@user.mfa_hashed_recovery_codes.any? { |code| BCrypt::Password.new(code) == recovery_code }) + end + end + + context "#api_mfa_verified?" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + end + + context "with totp" do + should "return true when correct" do + assert @user.api_mfa_verified?(ROTP::TOTP.new(@user.totp_seed).now) + end + + should "return true when correct in last interval" do + last_otp = ROTP::TOTP.new(@user.totp_seed).at(Time.current - 30) + + assert @user.api_mfa_verified?(last_otp) + end + + should "return true when correct in next interval" do + next_otp = ROTP::TOTP.new(@user.totp_seed).at(Time.current + 30) + + assert @user.api_mfa_verified?(next_otp) + end + + should "return false if otp is incorrect" do + refute @user.api_mfa_verified?(ROTP::TOTP.new(ROTP::Base32.random_base32).now) + end + end + + context "with webauthn otp" do + should "return true when correct" do + webauthn_verification = create(:webauthn_verification, user: @user) + + assert @user.api_mfa_verified?(webauthn_verification.otp) + end + + should "return false when incorrect" do + create(:webauthn_verification, user: @user, otp: "jiEm2mm2sJtRqAVx") + incorrect_otp = "Yxf57d1wEUSWyXrr" + + refute @user.api_mfa_verified?(incorrect_otp) + end + + should "return false when expired" do + webauthn_verification = create(:webauthn_verification, user: @user, otp_expires_at: 2.minutes.ago) + + refute @user.api_mfa_verified?(webauthn_verification.otp) + end + + context "when webauthn otp has not been generated" do + setup do + create(:webauthn_verification, user: @user, otp: nil, otp_expires_at: nil) + end + + should "return false for an otp" do + refute @user.api_mfa_verified?("Yxf57d1wEUSWyXrr") + end + + should "return false if otp is nil" do + refute @user.api_mfa_verified?(nil) + end + end + end + + should "return true if recovery code is correct" do + recovery_code = @user.new_mfa_recovery_codes.first + + assert @user.api_mfa_verified?(recovery_code) + refute(@user.mfa_hashed_recovery_codes.any? { |code| BCrypt::Password.new(code) == recovery_code }) + end + end + + context ".without_mfa" do + setup do + create(:user).enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + end + + should "return instances without mfa" do + user_without_mfa = User.without_mfa + + assert_equal 1, user_without_mfa.size + assert_equal @user, user_without_mfa.first + end + end +end diff --git a/test/models/concerns/user_totp_methods_test.rb b/test/models/concerns/user_totp_methods_test.rb new file mode 100644 index 00000000000..6729bc00bc2 --- /dev/null +++ b/test/models/concerns/user_totp_methods_test.rb @@ -0,0 +1,183 @@ +require "test_helper" + +class UserTotpMethodsTest < ActiveSupport::TestCase + include ActionMailer::TestHelper + + setup do + @user = create(:user) + end + + context "#totp_enabled?" do + should "return true if totp is enabled" do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + + assert_predicate @user, :totp_enabled? + end + + should "return false if totp is disabled" do + @user.disable_totp! + + refute_predicate @user, :totp_enabled? + end + end + + context "#totp_disabled?" do + should "return true if totp is disabled" do + @user.disable_totp! + + assert_predicate @user, :totp_disabled? + end + + should "return false if totp is enabled" do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + + refute_predicate @user, :totp_disabled? + end + end + + context "#disable_totp!" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + end + + should "set totp_seed to nil" do + perform_disable_totp_job + + assert_nil @user.totp_seed + end + + should "send totp disabled email" do + perform_disable_totp_job + + assert_emails 1 + + assert_equal "Authentication app disabled on RubyGems.org", last_email.subject + assert_equal [@user.email], last_email.to + end + + should "set mfa_level to disabled if webauthn is also disabled" do + perform_disable_totp_job + + assert_equal "disabled", @user.mfa_level + end + + should "maintain the mfa_level if webauthn is enabled" do + @credential = create(:webauthn_credential, user: @user) + perform_disable_totp_job + + assert_equal "ui_only", @user.mfa_level + end + + should "delete recovery codes if webauthn is disabled" do + perform_disable_totp_job + + assert_empty @user.mfa_hashed_recovery_codes + end + + context "when webauthn is enabled" do + setup do + @credential = create(:webauthn_credential, user: @user) + end + + should "not delete recovery codes" do + assert_no_changes ["@user.mfa_hashed_recovery_codes"] do + perform_disable_totp_job + end + end + end + end + + context "#verify_and_enable_totp!" do + setup do + @seed = ROTP::Base32.random_base32 + @expiry = 30.minutes.from_now + end + + should "enable mfa" do + @user.verify_and_enable_totp!( + @seed, + :ui_and_api, + ROTP::TOTP.new(@seed).now, + @expiry + ) + + assert_predicate @user, :mfa_enabled? + end + + should "add error if qr code expired" do + @user.verify_and_enable_totp!( + @seed, + :ui_and_api, + ROTP::TOTP.new(@seed).now, + 5.minutes.ago + ) + + refute_predicate @user, :mfa_enabled? + expected_error = "The QR-code and key is expired. Please try registering a new device again." + + assert_contains @user.errors[:base], expected_error + end + + should "add error if otp code is incorrect" do + @user.verify_and_enable_totp!( + @seed, + :ui_and_api, + ROTP::TOTP.new(ROTP::Base32.random_base32).now, + @expiry + ) + + refute_predicate @user, :mfa_enabled? + assert_contains @user.errors[:base], "Your OTP code is incorrect." + end + end + + context "#enable_totp!" do + setup do + @seed = ROTP::Base32.random_base32 + end + + context "if webauthn is disabled" do + should "enable mfa" do + assert_changes "@user.mfa_level", from: "disabled", to: "ui_and_api" do + @user.enable_totp!(@seed, "ui_and_api") + end + end + + should "set totp seed" do + assert_changes "@user.totp_seed", from: nil, to: @seed do + @user.enable_totp!(@seed, "ui_and_api") + end + end + + should "generate hashed recovery codes" do + assert_changes "@user.mfa_hashed_recovery_codes.length", 10 do + @user.enable_totp!(@seed, "ui_and_api") + end + end + end + + context "if webauthn is enabled" do + setup do + create(:webauthn_credential, user: @user) + end + + should "not reset mfa level and recovery codes" do + assert_no_changes ["@user.mfa_level", "@user.mfa_hashed_recovery_codes"] do + @user.enable_totp!(@seed, "ui_and_gem_signin") + end + end + + should "set totp seed" do + @user.enable_totp!(@seed, "ui_and_gem_signin") + + assert_equal @seed, @user.totp_seed + end + end + end + + def perform_disable_totp_job + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + @user.disable_totp! + end + end +end diff --git a/test/models/concerns/user_webauthn_methods_test.rb b/test/models/concerns/user_webauthn_methods_test.rb new file mode 100644 index 00000000000..f961c05ea48 --- /dev/null +++ b/test/models/concerns/user_webauthn_methods_test.rb @@ -0,0 +1,136 @@ +require "test_helper" + +class UserWebauthnMethodsTest < ActiveSupport::TestCase + setup do + @user = create(:user) + end + + context "create" do + should "set webauthn_id" do + refute_nil @user.webauthn_id + end + end + + context "#webauthn_enabled?" do + should "return true if webauthn is enabled" do + create(:webauthn_credential, user: @user) + + assert_predicate @user, :webauthn_enabled? + end + + should "return false if webauthn is disabled" do + refute_predicate @user, :webauthn_enabled? + end + end + + context "#webauthn_disabled?" do + should "return true if webauthn is disabled" do + assert_predicate @user, :webauthn_disabled? + end + + should "return false if webauthn is enabled" do + create(:webauthn_credential, user: @user) + + refute_predicate @user, :webauthn_disabled? + end + end + + context "#webauthn_only_with_recovery?" do + should "return true if webauthn is enabled, totp is disabled, and recovery codes are present" do + create(:webauthn_credential, user: @user) + + assert_predicate @user, :webauthn_only_with_recovery? + end + + should "return false if webauthn is disabled" do + refute_predicate @user, :webauthn_only_with_recovery? + end + + should "return false if totp is enabled" do + @user.enable_totp!(ROTP::Base32.random_base32, "ui_and_api") + + refute_predicate @user, :webauthn_only_with_recovery? + end + + should "return false if recovery codes are not present" do + create(:webauthn_credential, user: @user) + @user.new_mfa_recovery_codes = nil + @user.mfa_hashed_recovery_codes = [] + + refute_predicate @user, :webauthn_only_with_recovery? + end + end + + context "#webauthn_options_for_create" do + should "returns options with id, and name" do + user_create_options = @user.webauthn_options_for_create.user + + assert_equal @user.name, user_create_options.display_name + assert_equal @user.webauthn_id, user_create_options.id + end + + should "return an empty list for exclude if user does not have any prior existing webauthn credentials" do + create_options = @user.webauthn_options_for_create + + assert_empty create_options.exclude + end + + should "exclude pre-existing webauthn credentials when creating a new one" do + webauthn_credential = create(:webauthn_credential, user: @user) + create_options = @user.webauthn_options_for_create + + assert_equal [webauthn_credential.external_id], create_options.exclude + end + end + + context "#webauthn_options_for_get" do + setup do + @webauthn_credential = create(:webauthn_credential, user: @user) + end + + should "get prexisting webauthn credentials" do + get_options = @user.webauthn_options_for_get + + assert_equal [@webauthn_credential.external_id], get_options.allow + end + end + + context "#refresh_webauthn_verification" do + setup do + travel_to Time.utc(2023, 1, 1, 0, 0, 0) do + @webauthn_verification = @user.refresh_webauthn_verification + end + end + + should "create a token that is 16 characters long" do + assert_equal 16, @webauthn_verification.path_token.length + end + + should "set a 5 minute expiry" do + assert_equal Time.utc(2023, 1, 1, 0, 2, 0), @webauthn_verification.path_token_expires_at + end + + should "store a path token in the database" do + assert_equal @user.webauthn_verification.path_token, @webauthn_verification.path_token + end + + should "reset the token each time the method is called" do + token_before = @webauthn_verification.path_token + @user.refresh_webauthn_verification + + refute_equal token_before, @user.webauthn_verification.path_token + end + + should "reset the otp each time the method is called" do + @webauthn_verification.generate_otp + + assert_not_nil @user.webauthn_verification.otp + assert_not_nil @user.webauthn_verification.otp_expires_at + + @user.refresh_webauthn_verification + + assert_nil @user.webauthn_verification.otp + assert_nil @user.webauthn_verification.otp_expires_at + end + end +end diff --git a/test/models/deletion_test.rb b/test/models/deletion_test.rb new file mode 100644 index 00000000000..032f82c923c --- /dev/null +++ b/test/models/deletion_test.rb @@ -0,0 +1,278 @@ +require "test_helper" + +class DeletionTest < ActiveSupport::TestCase + include SearchKickHelper + include ActiveJob::TestHelper + + setup do + @user = create(:user) + @api_key = create(:api_key, owner: @user) + @gem_file = gem_file("test-0.0.0.gem") + Pusher.new(@api_key, @gem_file).process + @gem_file.rewind + @version = Version.last + @spec_rz = RubygemFs.instance.get("quick/Marshal.4.8/#{@version.full_name}.gemspec.rz") + import_and_refresh + end + + teardown do + @gem_file.close + end + + should "be indexed" do + @version.indexed = false + + assert_predicate Deletion.new(version: @version, user: @user), :invalid?, + "Deletion should only work on indexed gems" + end + + should "not be popular" do + GemDownload.increment( + 100_001, + rubygem_id: @version.rubygem.id, + version_id: @version.id + ) + + assert_predicate Deletion.new(version: @version, user: @user), :invalid?, + "Versions with more than 100_000 downloads should not be deletable" + end + + should "be forceable even when popular" do + GemDownload.increment( + 100_001, + rubygem_id: @version.rubygem.id, + version_id: @version.id + ) + + assert_predicate Deletion.new(version: @version, user: @user, force: true), :valid?, + "Admins should be allowed to delete popular versions when necessary" + end + + should "not be old" do + @version.update(created_at: 31.days.ago) + + assert_predicate Deletion.new(version: @version, user: @user), :invalid?, + "Versions older than 30 days should not be deletable" + end + + should "be forceable even when old" do + @version.update(created_at: 31.days.ago) + + assert_predicate Deletion.new(version: @version, user: @user, force: true), :valid?, + "Admins should be allowed to delete old versions when necessary" + end + + context "association" do + subject { Deletion.new(version: @version, user: @user) } + + should belong_to(:user).without_validating_presence + end + + context "with deleted gem" do + setup do + @gem_name = @version.rubygem.name + GemCachePurger.stubs(:call) + end + + context "when delete is called" do + setup do + delete_gem + end + + should "unindexes" do + refute_predicate @version, :indexed? + refute_predicate @version.rubygem, :indexed? + end + + should "be considered deleted" do + assert_includes Version.yanked, @version + end + + should "no longer be latest" do + refute_predicate @version.reload, :latest? + end + + should "keep the yanked time" do + assert @version.reload.yanked_at + end + + should "set the yanked info checksum" do + refute_nil @version.reload.yanked_info_checksum + end + + should "delete the .gem file" do + assert_nil RubygemFs.instance.get("gems/#{@version.full_name}.gem"), "Rubygem still exists!" + end + + should "delete the .gemspec.rz file" do + assert_nil RubygemFs.instance.get("quick/Marshal.4.8/#{@version.full_name}.gemspec.rz"), "Gemspec.rz still exists!" + end + + should "send gem yanked email" do + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob + + email = ActionMailer::Base.deliveries.last + + assert_equal "Gem #{@version.to_title} yanked from RubyGems.org", email.subject + assert_equal [@user.email], email.to + end + end + + should "call GemCachePurger" do + GemCachePurger.expects(:call).with(@gem_name) + + delete_gem + end + end + + should "enqueue yank version contents job" do + assert_enqueued_jobs 1, only: YankVersionContentsJob do + delete_gem + end + end + + context "when rstuf is enabled" do + setup do + setup_rstuf + end + + should "enqueue rstuf removal" do + assert_enqueued_with(job: Rstuf::RemoveJob, args: [{ version: @version }]) do + delete_gem + end + end + + teardown do + teardown_rstuf + end + end + + should "enque job for updating ES index, spec index and purging cdn" do + assert_enqueued_jobs 1, only: ActionMailer::MailDeliveryJob do + assert_enqueued_jobs 8, only: FastlyPurgeJob do + assert_enqueued_jobs 1, only: Indexer do + assert_enqueued_jobs 1, only: ReindexRubygemJob do + delete_gem + end + end + end + end + + perform_enqueued_jobs + + response = Searchkick.client.get index: "rubygems-#{Rails.env}", + id: @version.rubygem_id + + assert response["_source"]["yanked"] + end + + should "record version metadata" do + deletion = Deletion.new(version: @version, user: @user) + + assert_nil deletion.rubygem + deletion.valid? + + assert_equal deletion.rubygem, @version.rubygem.name + assert_equal @version.id, deletion.version_id + end + + context "with restored gem" do + setup do + @gem_name = @version.rubygem.name + GemCachePurger.stubs(:call) + RubygemFs.instance.stubs(:restore).with do |file| + case file + when "gems/#{@version.full_name}.gem" + RubygemFs.instance.store(file, @gem_file.read) + when "quick/Marshal.4.8/#{@version.full_name}.gemspec.rz" + RubygemFs.instance.store(file, @spec_rz) + end + end.returns(true) + end + + context "when gem is deleted and restored" do + setup do + @deletion = delete_gem + @deletion.restore! + end + + should "index rubygem and version" do + assert_predicate @version.rubygem, :indexed? + assert_predicate @version, :indexed? + end + + should "reorder versions" do + assert_predicate @version.reload, :latest? + end + + should "remove the yanked time and yanked_info_checksum" do + assert_nil @version.yanked_at + assert_nil @version.yanked_info_checksum + end + + should "purge fastly" do + Fastly.expects(:purge).with({ path: "info/#{@version.rubygem.name}", soft: true }) + Fastly.expects(:purge).with({ path: "names", soft: true }) + Fastly.expects(:purge).with({ path: "versions", soft: true }) + Fastly.expects(:purge).with({ path: "gem/#{@version.rubygem.name}", soft: true }) + + Fastly.expects(:purge).with({ path: "gems/#{@version.full_name}.gem", soft: false }).times(2) + Fastly.expects(:purge).with({ path: "quick/Marshal.4.8/#{@version.full_name}.gemspec.rz", soft: false }).times(2) + + perform_enqueued_jobs(only: FastlyPurgeJob) + end + + should "remove deletion record" do + assert_predicate @deletion, :destroyed? + end + end + + should "call GemCachePurger" do + GemCachePurger.expects(:call).with(@gem_name).times(2) + + @deletion = delete_gem + @deletion.restore! + end + + should "enqueue store version contents job" do + @deletion = delete_gem + assert_enqueued_jobs 1, only: StoreVersionContentsJob do + @deletion.restore! + end + end + + context "with rstuf enabled" do + setup do + setup_rstuf + end + + should "enqueue rstuf addition" do + @deletion = delete_gem + assert_enqueued_jobs 1, only: Rstuf::AddJob do + @deletion.restore! + end + end + + teardown do + teardown_rstuf + end + end + + should "enqueue indexing jobs" do + @deletion = delete_gem + assert_enqueued_jobs 1, only: Indexer do + assert_enqueued_jobs 1, only: UploadVersionsFileJob do + assert_enqueued_with job: UploadInfoFileJob, args: [{ rubygem_name: @gem_name }] do + @deletion.restore! + end + end + end + end + end + + private + + def delete_gem + Deletion.create!(version: @version, user: @user) + end +end diff --git a/test/unit/dependency_test.rb b/test/models/dependency_test.rb similarity index 88% rename from test/unit/dependency_test.rb rename to test/models/dependency_test.rb index 5f2aa211aa0..5b46f30bce8 100644 --- a/test/unit/dependency_test.rb +++ b/test/models/dependency_test.rb @@ -15,22 +15,25 @@ class DependencyTest < ActiveSupport::TestCase end should "be valid with factory" do - assert @dependency.valid? + assert_predicate @dependency, :valid? end should "be invalid with requirements longer than maximum field length" do long_requirement_suffix = ".0" * (Gemcutter::MAX_FIELD_LENGTH + 1) @dependency.gem_dependency = Gem::Dependency.new("holla", ["= 0#{long_requirement_suffix}"]) - refute @dependency.valid? - assert_equal @dependency.errors.messages[:requirements], ["is too long (maximum is 255 characters)"] + + refute_predicate @dependency, :valid? + assert_equal ["is too long (maximum is 255 characters)", "must be list of valid requirements"], + @dependency.errors.messages[:requirements] end should "be invalid with unresolved_name longer than maximum field length" do long_unresolved_name = "r" * (Gemcutter::MAX_FIELD_LENGTH + 1) gem_dependency = Gem::Dependency.new(long_unresolved_name, ["= 0.0.0"]) dependency = Dependency.create(gem_dependency: gem_dependency) - refute dependency.valid? - assert_equal dependency.errors.messages[:unresolved_name], ["is too long (maximum is 255 characters)"] + + refute_predicate dependency, :valid? + assert_equal ["is too long (maximum is 255 characters)"], dependency.errors.messages[:unresolved_name] end should "return JSON" do @@ -121,7 +124,7 @@ class DependencyTest < ActiveSupport::TestCase @specification = gem_specification_from_gem_fixture("with_dependencies-0.0.0") @rubygem = Rubygem.new(name: @specification.name) @version = @rubygem.find_or_initialize_version_from_spec(@specification) - @version.sha256 = "dummy" + @version.sha256 = Digest::SHA256.base64digest("dummy") @rubygem.update_attributes_from_gem_specification!(@version, @specification) @@ -131,8 +134,9 @@ class DependencyTest < ActiveSupport::TestCase should "create a Dependency but not a rubygem" do dependency = Dependency.create(gem_dependency: @gem_dependency, version: @version) - refute dependency.new_record? - refute dependency.errors[:base].present? + + refute_predicate dependency, :new_record? + refute_predicate dependency.errors[:base], :present? assert_nil Rubygem.find_by(name: @rubygem_name) assert_equal "other-name", dependency.unresolved_name @@ -144,8 +148,9 @@ class DependencyTest < ActiveSupport::TestCase context "without using Gem::Dependency" do should "be invalid" do dependency = Dependency.create(gem_dependency: ["ruby-ajp", ">= 0.2.0"]) - assert dependency.new_record? - assert dependency.errors[:rubygem].present? + + assert_predicate dependency, :new_record? + assert_predicate dependency.errors[:rubygem], :present? end end @@ -156,8 +161,9 @@ class DependencyTest < ActiveSupport::TestCase should "not create a Dependency" do dependency = Dependency.create(gem_dependency: @gem_dependency) - assert dependency.new_record? - assert dependency.errors[:rubygem].present? + + assert_predicate dependency, :new_record? + assert_predicate dependency.errors[:rubygem], :present? assert_nil Rubygem.find_by(name: "") end end diff --git a/test/models/events/rubygem_event_test.rb b/test/models/events/rubygem_event_test.rb new file mode 100644 index 00000000000..053b307b808 --- /dev/null +++ b/test/models/events/rubygem_event_test.rb @@ -0,0 +1,9 @@ +require "test_helper" + +class Events::RubygemEventTest < ActiveSupport::TestCase + should belong_to(:rubygem) + should belong_to(:ip_address).optional + should belong_to(:geoip_info).optional + should validate_presence_of(:tag) + should validate_inclusion_of(:tag).in_array(Events::RubygemEvent.tags.keys) +end diff --git a/test/models/events/user_agent_info_test.rb b/test/models/events/user_agent_info_test.rb new file mode 100644 index 00000000000..55507df004b --- /dev/null +++ b/test/models/events/user_agent_info_test.rb @@ -0,0 +1,18 @@ +require "test_helper" + +class Events::UserAgentInfoTest < ActiveSupport::TestCase + test "#to_s" do + assert_equal "user_agent (os on device)", build(:events_user_agent_info, installer: "Browser").to_s + assert_equal "user_agent (os)", build(:events_user_agent_info, installer: "Browser", device: "Other").to_s + assert_equal "user_agent (device)", build(:events_user_agent_info, installer: "Browser", os: "Other").to_s + assert_equal "user_agent", build(:events_user_agent_info, installer: "Browser", os: "Other", device: "Other").to_s + assert_equal "Unknown browser", build(:events_user_agent_info, installer: "Browser", device: "Other", os: "Other", user_agent: "Other").to_s + + assert_equal "installer (implementation on system)", build(:events_user_agent_info).to_s + assert_equal "installer (implementation)", build(:events_user_agent_info, system: nil).to_s + assert_equal "installer (system)", build(:events_user_agent_info, implementation: nil).to_s + assert_equal "installer", build(:events_user_agent_info, system: nil, implementation: nil).to_s + + assert_equal "Unknown user agent", build(:events_user_agent_info, installer: nil).to_s + end +end diff --git a/test/models/events/user_event_test.rb b/test/models/events/user_event_test.rb new file mode 100644 index 00000000000..d9f11ec902c --- /dev/null +++ b/test/models/events/user_event_test.rb @@ -0,0 +1,9 @@ +require "test_helper" + +class Events::UserEventTest < ActiveSupport::TestCase + should belong_to(:user) + should belong_to(:ip_address).optional + should belong_to(:geoip_info).optional + should validate_presence_of(:tag) + should validate_inclusion_of(:tag).in_array(Events::UserEvent.tags.keys) +end diff --git a/test/unit/gem_download_test.rb b/test/models/gem_download_test.rb similarity index 93% rename from test/unit/gem_download_test.rb rename to test/models/gem_download_test.rb index c560c472f8c..fa52869b656 100644 --- a/test/unit/gem_download_test.rb +++ b/test/models/gem_download_test.rb @@ -1,21 +1,23 @@ require "test_helper" class GemDownloadTest < ActiveSupport::TestCase - include ESHelper + include SearchKickHelper setup do create(:gem_download, count: 0) - Rubygem.__elasticsearch__.create_index! force: true + import_and_refresh end context ".increment" do should "not update if download doesnt exist" do - assert_nil GemDownload.increment(1, rubygem_id: 1) + assert_equal 0, GemDownload.increment(1, rubygem_id: 1) end should "not update if download count is nil" do - create(:gem_download, rubygem_id: 1, version_id: 0, count: nil) - download = GemDownload.increment(1, rubygem_id: 1) + download = create(:gem_download, rubygem_id: 1, version_id: 0, count: nil) + GemDownload.increment(1, rubygem_id: 1) + download.reload + assert_nil download.count end @@ -56,7 +58,7 @@ def test_update_the_count_atomically context ".bulk_update" do context "with multiple versions of same gem" do setup do - @versions = Array.new(2) { create(:version) } + @versions = create_list(:version, 2) @gems = @versions.map(&:rubygem) @versions << create(:version, rubygem: @gems[0]) @counts = Array.new(3) { rand(100) } @@ -67,6 +69,7 @@ def test_update_the_count_atomically should "write the proper values" do GemDownload.bulk_update(@data) + 3.times do |i| assert_equal @counts[i], GemDownload.count_for_version(@versions[i].id) end @@ -81,7 +84,8 @@ def test_update_the_count_atomically requires_toxiproxy Toxiproxy[:elasticsearch].down do GemDownload.bulk_update(@data) - total_count = @counts.inject(0, :+) + total_count = @counts.sum + assert_equal total_count, GemDownload.total_count end end @@ -113,7 +117,8 @@ def test_update_the_count_atomically should "set version_downloads of ES record with most_recent version downloads" do 2.times.each do |i| - most_recent_version = @gems[i].versions.most_recent + most_recent_version = @gems[i].most_recent_version + assert_equal most_recent_version.downloads_count, es_version_downloads(@gems[i].id) end end @@ -123,7 +128,7 @@ def test_update_the_count_atomically setup do @rubygem = create(:rubygem, number: "0.0.1.rc") import_and_refresh - most_recent_version = @rubygem.versions.most_recent + most_recent_version = @rubygem.most_recent_version version_downloads = [most_recent_version.full_name, 40] GemDownload.bulk_update([version_downloads]) end @@ -149,7 +154,7 @@ def test_update_the_count_atomically context ".most_downloaded_gem_count" do setup do - versions = Array.new(20) { create(:version) } + versions = create_list(:version, 20) # rubocop:disable FactoryBot/ExcessiveCreateList @counts = Array.new(20) { rand(100) } data = versions.map.with_index { |v, i| [v.full_name, @counts[i]] } @@ -163,16 +168,19 @@ def test_update_the_count_atomically should "not count, wrong named versions" do GemDownload.bulk_update([["foonotexists", 100]]) + assert_equal 0, GemDownload.total_count version = create(:version) GemDownload.bulk_update([["foonotexists", 100], ["dddd", 50], [version.full_name, 2]]) + assert_equal 2, GemDownload.total_count end should "write global downloads count" do counts = Array.new(3) { [create(:version).full_name, 2] } GemDownload.bulk_update(counts) + assert_equal 6, GemDownload.total_count end @@ -194,6 +202,7 @@ def test_update_the_count_atomically version = create(:version) counts = Array.new(3) { |n| [version.full_name, n + 1] } GemDownload.bulk_update(counts) + assert_equal 6, GemDownload.count_for_version(version.id) end @@ -216,6 +225,7 @@ def test_update_the_count_atomically 2.times { GemDownload.increment(1, rubygem_id: @rubygem_1, version_id: @version_2.id) } gem_download_order = [@version_3, @version_2, @version_1, @version_4].map(&:gem_download) + assert_equal gem_download_order, GemDownload.most_downloaded_gems end diff --git a/test/unit/gem_info_test.rb b/test/models/gem_info_test.rb similarity index 76% rename from test/unit/gem_info_test.rb rename to test/models/gem_info_test.rb index d205d82161c..0e2196dbc0c 100644 --- a/test/unit/gem_info_test.rb +++ b/test/models/gem_info_test.rb @@ -25,6 +25,7 @@ class GemInfoTest < ActiveSupport::TestCase should "return gem version and dependency" do info = GemInfo.new("example").compact_index_info + assert_equal @expected_info, info end @@ -45,13 +46,16 @@ class GemInfoTest < ActiveSupport::TestCase context ".ordered_names" do setup do - %w[abc bcd abd].each { |name| create(:rubygem, name: name) } + %w[abc bcd abd].each { |name| create(:rubygem, name:, indexed: true) } + + create(:rubygem, name: "abe", indexed: false) @ordered_names = %w[abc abd bcd] end should "order rubygems by name" do names = GemInfo.ordered_names + assert_equal @ordered_names, names end @@ -84,7 +88,28 @@ class GemInfoTest < ActiveSupport::TestCase should "return all versions created after given date and ordered by created_at" do versions = GemInfo.compact_index_versions(4.days.ago) + assert_equal @expected_versions, versions end end + + context ".compact_index_public_versions" do + setup do + @ts = 5.minutes.ago + @version = create(:version, number: "0.0.1", created_at: @ts, info_checksum: "qw2dwe") + + _updated_after_ts = create(:version, number: "2.0.0", created_at: @ts + 1.second) + end + + should "not return version updated after ts" do + versions = GemInfo.compact_index_public_versions(@ts) + + expected_versions = [CompactIndex::Gem.new( + @version.rubygem.name, + [CompactIndex::GemVersion.new(@version.number, @version.platform, @version.sha256, @version.info_checksum)] + )] + + assert_equal expected_versions, versions + end + end end diff --git a/test/models/gem_name_reservation_test.rb b/test/models/gem_name_reservation_test.rb new file mode 100644 index 00000000000..809355804e0 --- /dev/null +++ b/test/models/gem_name_reservation_test.rb @@ -0,0 +1,36 @@ +require "test_helper" + +class GemNameReservationTest < ActiveSupport::TestCase + context "with a saved reservation" do + setup do + @reservation = create(:gem_name_reservation) + end + + subject { @reservation } + + should_not allow_value(nil).for(:name) + should_not allow_value("").for(:name) + should_not allow_value("Abc").for(:name) + should allow_value("abc").for(:name) + should validate_uniqueness_of(:name).case_insensitive + should validate_length_of(:name).is_at_most(Gemcutter::MAX_FIELD_LENGTH) + end + + context "#reserved?" do + should "recognize reserved gem name" do + create(:gem_name_reservation, name: "reserved-gem-name") + + assert GemNameReservation.reserved?("reserved-gem-name") + end + + should "recognize reserved case insensitive gem name" do + create(:gem_name_reservation, name: "reserved-gem-name") + + assert GemNameReservation.reserved?("RESERVED-gem-name") + end + + should "recognize not reserved gem name" do + refute GemNameReservation.reserved?("totally-random-gem-name") + end + end +end diff --git a/test/unit/gem_typo_exception_test.rb b/test/models/gem_typo_exception_test.rb similarity index 68% rename from test/unit/gem_typo_exception_test.rb rename to test/models/gem_typo_exception_test.rb index 9f8d1cb22ad..1dc77b7fe9a 100644 --- a/test/unit/gem_typo_exception_test.rb +++ b/test/models/gem_typo_exception_test.rb @@ -3,21 +3,24 @@ class GemTypoExceptionTest < ActiveSupport::TestCase context "name validations" do should validate_uniqueness_of(:name).case_insensitive + should validate_length_of(:name).is_at_most(Gemcutter::MAX_FIELD_LENGTH) should "be a valid factory" do - assert build(:gem_typo_exception).valid? + assert_predicate build(:gem_typo_exception), :valid? end should "be invalid with an empty string" do exception = build(:gem_typo_exception, name: "") - refute exception.valid? + + refute_predicate exception, :valid? end should "be invalid when gem name exists" do create(:rubygem, name: "some") exception = build(:gem_typo_exception, name: "some") - refute exception.valid? + + refute_predicate exception, :valid? end end end diff --git a/test/unit/gem_typo_test.rb b/test/models/gem_typo_test.rb similarity index 81% rename from test/unit/gem_typo_test.rb rename to test/models/gem_typo_test.rb index b5b54ebba71..f3d80c25d94 100644 --- a/test/unit/gem_typo_test.rb +++ b/test/models/gem_typo_test.rb @@ -11,7 +11,8 @@ class GemTypoTest < ActiveSupport::TestCase should "return false for exact match" do gem_typo = GemTypo.new("delayed_job_active_record") - refute gem_typo.protected_typo? + + refute_predicate gem_typo, :protected_typo? end should "return false for any exact match so that owner of the delayed_job_active_record Gem can push an update with existing typosquat" do @@ -20,48 +21,57 @@ class GemTypoTest < ActiveSupport::TestCase create(:version, rubygem: existing_typo, created_at: Time.now.utc) gem_typo = GemTypo.new("delayed_job_active_record") - refute gem_typo.protected_typo? + + refute_predicate gem_typo, :protected_typo? end should "return false for an exact match of a yanked gem so a gem with an identical name can be published in the future" do gem_typo = GemTypo.new("deleted_active_record_gem") - refute gem_typo.protected_typo? + + refute_predicate gem_typo, :protected_typo? end should "return false for a underscore variation match of a yanked gem so a gem with a similar name can be published in the future" do gem_typo = GemTypo.new("deleted-active_record-gem") - refute gem_typo.protected_typo? + + refute_predicate gem_typo, :protected_typo? end context "typo squat on an existing Gem name" do should "return true for one -/_ character change" do gem_typo = GemTypo.new("delayed-job_active_record") - assert gem_typo.protected_typo? + + assert_predicate gem_typo, :protected_typo? end should "return true for one -/_ missing" do gem_typo = GemTypo.new("delayed_job_activerecord") - assert gem_typo.protected_typo? + + assert_predicate gem_typo, :protected_typo? end should "return true for two -/_ change" do gem_typo = GemTypo.new("delayed-job_active-record") - assert gem_typo.protected_typo? + + assert_predicate gem_typo, :protected_typo? end should "return true for two -/_ changed/missing" do gem_typo = GemTypo.new("delayed-jobactive-record") - assert gem_typo.protected_typo? + + assert_predicate gem_typo, :protected_typo? end should "return true for three -/_ character change" do gem_typo = GemTypo.new("delayed-job-active-record") - assert gem_typo.protected_typo? + + assert_predicate gem_typo, :protected_typo? end should "return true for three -/_ missing" do gem_typo = GemTypo.new("delayedjobactiverecord") - assert gem_typo.protected_typo? + + assert_predicate gem_typo, :protected_typo? end end @@ -74,13 +84,13 @@ class GemTypoTest < ActiveSupport::TestCase should "return false when most recent release was more than GemTypo::LAST_RELEASE_TIME ago" do create(:version, rubygem: @existing, created_at: 6.years.ago) - refute @gem_typo.protected_typo? + refute_predicate @gem_typo, :protected_typo? end should "return true when most recent release was less than GemTypo::LAST_RELEASE_TIME ago" do create(:version, rubygem: @existing, created_at: 4.years.ago) - assert @gem_typo.protected_typo? + assert_predicate @gem_typo, :protected_typo? end end end diff --git a/test/models/geoip_info_test.rb b/test/models/geoip_info_test.rb new file mode 100644 index 00000000000..6846f67c3bd --- /dev/null +++ b/test/models/geoip_info_test.rb @@ -0,0 +1,14 @@ +require "test_helper" + +class GeoipInfoTest < ActiveSupport::TestCase + should have_many(:ip_addresses).dependent(:nullify) + should have_many(:user_events).class_name("Events::UserEvent").dependent(:nullify) + should have_many(:rubygem_events).class_name("Events::RubygemEvent").dependent(:nullify) + + test "#to_s" do + assert_equal "Buffalo, NY, US", build(:geoip_info).to_s + assert_equal "United States of America", + build(:geoip_info, city: nil, region: nil, country_code: nil, country_name: "United States of America").to_s + assert_equal "Unknown", build(:geoip_info, city: nil, region: nil, country_code: nil, country_name: nil).to_s + end +end diff --git a/test/models/ip_address_test.rb b/test/models/ip_address_test.rb new file mode 100644 index 00000000000..5abf5c1cabf --- /dev/null +++ b/test/models/ip_address_test.rb @@ -0,0 +1,9 @@ +require "test_helper" + +class IpAddressTest < ActiveSupport::TestCase + subject { create(:ip_address) } + + should validate_presence_of(:ip_address) + should validate_uniqueness_of(:ip_address) + should validate_uniqueness_of(:hashed_ip_address) +end diff --git a/test/models/link_verification_test.rb b/test/models/link_verification_test.rb new file mode 100644 index 00000000000..0c1ed8045e0 --- /dev/null +++ b/test/models/link_verification_test.rb @@ -0,0 +1,98 @@ +require "test_helper" + +class LinkVerificationTest < ActiveSupport::TestCase + should belong_to :linkable + + def verification!(name, **) + instance_variable_set :"@#{name}", create(:link_verification, uri: "https://example.com/#{name}", **) + end + + setup do + freeze_time + + @rubygem = create(:rubygem, linkset: build(:linkset, home: nil)) # using a single rubygem to avoid creating a bunch of homepage link verifications + verification!("unverified", linkable: @rubygem) + verification!("expired", linkable: @rubygem, last_verified_at: 2.months.ago) + verification!("expired_and_failed", linkable: @rubygem, last_verified_at: 2.months.ago, last_failure_at: 6.weeks.ago, + failures_since_last_verification: LinkVerification::MAX_FAILURES) + verification!("failed", linkable: @rubygem, last_failure_at: 1.minute.ago, + failures_since_last_verification: LinkVerification::MAX_FAILURES) + verification!("verified", linkable: @rubygem, last_verified_at: 1.week.ago) + verification!("pending", linkable: @rubygem, last_verified_at: 25.days.ago) + verification!("reverifying", linkable: @rubygem, last_verified_at: 25.days.ago, failures_since_last_verification: 4) + verification!("http", linkable: @rubygem, uri: "http://example.com/http") + verification!("invalid_uri", linkable: @rubygem, uri: " ") + end + + context "scopes" do + should "return verified" do + assert_equal [@verified, @pending, @reverifying].map(&:uri).sort, + @rubygem.link_verifications.verified.pluck(:uri).sort + end + + should "return unverified" do + assert_equal [@http, @unverified, @expired, @failed, @invalid_uri, @expired_and_failed].map(&:uri).sort, + @rubygem.link_verifications.unverified.pluck(:uri).sort + end + + should "return never_verified" do + assert_equal [@unverified, @invalid_uri, @http, @failed].map(&:uri).sort, + @rubygem.link_verifications.never_verified.pluck(:uri).sort + end + + should "return last_verified_before" do + assert_equal [@expired, @expired_and_failed].map(&:uri).sort, + @rubygem.link_verifications.last_verified_before(1.month.ago).pluck(:uri).sort + end + + should "return pending_verification" do + assert_equal [@pending, @expired, @unverified].map(&:uri).sort, + @rubygem.link_verifications.pending_verification.pluck(:uri).sort + end + end + + context "#verified?" do + should "return true for verified" do + [@verified, @pending, @reverifying].each do |v| + assert_predicate v, :verified? + end + end + + should "return false for unverified" do + [@unverified, @expired, @failed, @http, @expired_and_failed].each do |v| + refute_predicate v, :verified? + end + end + end + + context "#unverified?" do + should "return true for unverified" do + [@unverified, @expired, @failed, @http, @expired_and_failed, @expired].each do |v| + assert_predicate v, :unverified? + end + end + + should "return false for verified" do + [@verified, @pending, @reverifying].each do |v| + refute_predicate v, :unverified? + end + end + end + + context "#should_verify?" do + should "return true" do + assert_predicate @unverified, :should_verify? + assert_predicate @pending, :should_verify? + assert_predicate @expired, :should_verify? + assert_predicate @expired, :should_verify? + end + + should "return false" do + refute_predicate @http, :should_verify? + refute_predicate @expired_and_failed, :should_verify? + refute_predicate @failed, :should_verify? + refute_predicate @invalid_uri, :should_verify? + refute_predicate @reverifying, :should_verify? + end + end +end diff --git a/test/unit/links_test.rb b/test/models/links_test.rb similarity index 77% rename from test/unit/links_test.rb rename to test/models/links_test.rb index f985475e602..050f95a381e 100644 --- a/test/unit/links_test.rb +++ b/test/models/links_test.rb @@ -56,7 +56,20 @@ class LinksTest < ActiveSupport::TestCase assert links.homepage_uri end - context "metadata includes non whitelisted uri key" do + should "not produce duplicates when indexed" do + metadata = { "homepage_uri" => "https://example.code", "code_uri" => "https://example.code" } + version = build(:version, indexed: true, metadata: metadata) + rubygem = build(:rubygem, linkset: build(:linkset), versions: [version]) + links = rubygem.links(version) + + enumerated = links.map do |short, value| + [short, value] + end + + assert_equal([["home", "https://example.code"], ["download", "/downloads/.gem"]], enumerated) + end + + context "metadata includes unknown uri key" do setup do metadata = { "homepage_uri" => "https://example.com", @@ -67,7 +80,7 @@ class LinksTest < ActiveSupport::TestCase "funding_uri" => "https://example.com", "documentation_uri" => "https://example.com", "changelog_uri" => "https://example.com", - "non_whitelisted_uri" => "https://example.com" + "unknown_uri" => "https://example.com" } version = build(:version, metadata: metadata) @@ -75,15 +88,16 @@ class LinksTest < ActiveSupport::TestCase @links = rubygem.links(version) end - should "create method for whitelisted keys" do - whitelisted_keys = Links::LINKS.values.reject! { |k| k == "download_uri" } - whitelisted_keys.each do |key| + should "create method for known keys" do + known_keys = Links::LINKS.values.reject! { |k| k == "download_uri" } + + known_keys.each do |key| assert_equal "https://example.com", @links.send(key), "value doesn't match for method: #{key}" end end - should "not create method for non whitelisted key" do - refute @links.respond_to?("non_whitelisted_uri") + should "not create method for unknown key" do + refute_respond_to @links, "unknown_uri" end end end diff --git a/test/unit/linkset_test.rb b/test/models/linkset_test.rb similarity index 67% rename from test/unit/linkset_test.rb rename to test/models/linkset_test.rb index 21937438a10..a57d2c58db9 100644 --- a/test/unit/linkset_test.rb +++ b/test/models/linkset_test.rb @@ -9,18 +9,19 @@ class LinksetTest < ActiveSupport::TestCase end should "be valid with factory" do - assert @linkset.valid? + assert_predicate @linkset, :valid? end should "not be empty with some links filled out" do - refute @linkset.empty? + refute_empty @linkset end should "be empty with no links filled out" do Linkset::LINKS.each do |link| - @linkset.send("#{link}=", nil) + @linkset.send(:"#{link}=", nil) end - assert @linkset.empty? + + assert_empty @linkset end end @@ -35,4 +36,11 @@ class LinksetTest < ActiveSupport::TestCase assert_equal @spec.homepage, @linkset.home end end + + context "validations" do + %w[home code docs wiki mail bugs].each do |link| + should allow_value("http://example.com").for(link.to_sym) + should validate_length_of(link).is_at_most(Gemcutter::MAX_FIELD_LENGTH) + end + end end diff --git a/test/unit/log_ticket_test.rb b/test/models/log_ticket_test.rb similarity index 91% rename from test/unit/log_ticket_test.rb rename to test/models/log_ticket_test.rb index 84c6085700c..876c42587fb 100644 --- a/test/unit/log_ticket_test.rb +++ b/test/models/log_ticket_test.rb @@ -12,8 +12,10 @@ class LogTicketTest < ActiveSupport::TestCase end should "allow different keys for the same directory" do - LogTicket.create!(directory: "test", key: "bar") - LogTicket.create!(directory: "test", key: "baz") + assert_nothing_raised do + LogTicket.create!(directory: "test", key: "bar") + LogTicket.create!(directory: "test", key: "baz") + end end context "#pop" do @@ -24,6 +26,7 @@ class LogTicketTest < ActiveSupport::TestCase context "without any key" do should "pop the first inputed" do ticket = LogTicket.pop + assert_equal "foo", ticket.key end end @@ -31,6 +34,7 @@ class LogTicketTest < ActiveSupport::TestCase context "with a key" do should "pop the key" do ticket = LogTicket.pop(key: "bar") + assert_equal "bar", ticket.key end end @@ -40,17 +44,20 @@ class LogTicketTest < ActiveSupport::TestCase LogTicket.create!(directory: "test/2", key: "boo", status: "pending") ticket = LogTicket.pop(directory: "test/2") + assert_equal "bar", ticket.key end end should "change the status" do ticket = LogTicket.pop + assert_equal "processing", ticket.status end should "return nil after no more items are in the queue" do 2.times { LogTicket.pop } + assert_nil LogTicket.pop end end @@ -67,7 +74,7 @@ class LogTicketTest < ActiveSupport::TestCase end should "set the right base directory" do - assert_equal "test", @log_ticket.fs.base_dir + assert_equal Rails.root.join("test"), @log_ticket.fs.base_dir end should "body return the file body" do diff --git a/test/models/membership_test.rb b/test/models/membership_test.rb new file mode 100644 index 00000000000..08d0bedce0b --- /dev/null +++ b/test/models/membership_test.rb @@ -0,0 +1,31 @@ +require "test_helper" + +class MembershipTest < ActiveSupport::TestCase + should belong_to(:organization) + should belong_to(:user) + + setup do + @organization = FactoryBot.create(:organization) + @user = FactoryBot.create(:user) + end + + should "be unconfirmed by default" do + membership = Membership.create!(organization: @organization, user: @user) + + assert_not(membership.confirmed?) + assert_empty(Membership.confirmed) + end + + should "be confirmed with confirmed_at" do + membership = Membership.create!(organization: @organization, user: @user, confirmed_at: Time.zone.now) + + assert_predicate(membership, :confirmed?) + assert_equal(Membership.confirmed, [membership]) + end + + should "have a default role" do + membership = Membership.create!(organization: @organization, user: @user) + + assert_predicate membership, :maintainer? + end +end diff --git a/test/models/oidc/access_policy_test.rb b/test/models/oidc/access_policy_test.rb new file mode 100644 index 00000000000..4f8b9b433d1 --- /dev/null +++ b/test/models/oidc/access_policy_test.rb @@ -0,0 +1,160 @@ +require "test_helper" + +class OIDC::AccessPolicyTest < ActiveSupport::TestCase + make_my_diffs_pretty! + + should validate_presence_of :statements + + setup do + @role = build(:oidc_api_key_role) + end + + context "#verify_access!" do + context "with an unknown effect on matching statement" do + setup do + @access_policy = OIDC::AccessPolicy.new(statements: [{ + effect: "unknown", + principal: { oidc: "iss" }, + conditions: [] + }]) + end + + should "raise error" do + jwt = JSON::JWT.new({ iss: "iss" }) + assert_raise("Unhandled effect unknown") { @access_policy.verify_access!(jwt) } + end + + should "fail to validate" do + @access_policy.validate + + assert_equal ["is not included in the list"], @access_policy.errors.messages[:"statements[0].effect"] + end + end + + context "with an explicit deny" do + setup do + @access_policy = OIDC::AccessPolicy.new(statements: [{ + effect: "deny", + principal: { oidc: "iss" }, + conditions: [] + }]) + end + + should "raise AccessError" do + jwt = JSON::JWT.new({ iss: "iss" }) + assert_raise(OIDC::AccessPolicy::AccessError) { @access_policy.verify_access!(jwt) } + end + end + + context "with no statements" do + setup do + @access_policy = OIDC::AccessPolicy.new(statements: []) + end + + should "raise AccessError" do + jwt = JSON::JWT.new({ iss: "iss" }) + assert_raise(OIDC::AccessPolicy::AccessError) { @access_policy.verify_access!(jwt) } + end + end + + context "with string_equals condition" do + setup do + @access_policy = OIDC::AccessPolicy.new(statements: [{ + effect: "allow", + principal: { oidc: "iss" }, + conditions: [{ + operator: "string_equals", + claim: "c", + value: "value" + }] + }]) + end + + should "raise AccessError when unequal" do + jwt = JSON::JWT.new({ iss: "iss", c: "not_value" }) + assert_raise(OIDC::AccessPolicy::AccessError) { @access_policy.verify_access!(jwt) } + end + + should "return nil when equal" do + jwt = JSON::JWT.new({ iss: "iss", c: "value" }) + + assert_nil @access_policy.verify_access!(jwt) + end + end + + context "with string_matches condition" do + setup do + @access_policy = OIDC::AccessPolicy.new(statements: [{ + effect: "allow", + principal: { oidc: "iss" }, + conditions: [{ + operator: "string_matches", + claim: "c", + value: "\\A[v].{3}e.*" + }] + }]) + end + + should "raise AccessError when no match" do + jwt = JSON::JWT.new({ iss: "iss", c: "not_value" }) + assert_raise(OIDC::AccessPolicy::AccessError) { @access_policy.verify_access!(jwt) } + end + + should "return nil when matches" do + jwt = JSON::JWT.new({ iss: "iss", c: "value" }) + + assert_nil @access_policy.verify_access!(jwt) + end + end + + context "with condition with unknown operator" do + setup do + @access_policy = OIDC::AccessPolicy.new(statements: [{ + effect: "allow", + principal: { oidc: "iss" }, + conditions: [{ + operator: "unknown", + claim: "c", + value: "" + }] + }]) + end + + should "raise" do + jwt = JSON::JWT.new({ iss: "iss" }) + assert_raise('Unknown operator "unknown"') { @access_policy.verify_access!(jwt) } + end + + should "fail to validate" do + @access_policy.validate + + assert_equal ["is not included in the list"], @access_policy.errors.messages[:"statements[0].conditions[0].operator"] + end + end + + context "with condition with wrong value type" do + setup do + @access_policy = OIDC::AccessPolicy.new(statements: [{ + effect: "allow", + principal: { oidc: "iss" }, + conditions: [{ + operator: "string_equals", + claim: "c", + value: 3 + }] + }]) + end + + should "raise" do + jwt = JSON::JWT.new({ iss: "iss" }) + assert_raise('Unknown operator "unknown"') { @access_policy.verify_access!(jwt) } + end + + should "fail to validate" do + @access_policy.validate + + assert_equal ["must be String"], @access_policy.errors.messages[:"statements[0].conditions[0].value"] + end + end + end +end diff --git a/test/models/oidc/api_key_permissions_test.rb b/test/models/oidc/api_key_permissions_test.rb new file mode 100644 index 00000000000..6299861c8c9 --- /dev/null +++ b/test/models/oidc/api_key_permissions_test.rb @@ -0,0 +1,29 @@ +require "test_helper" + +class OIDC::ApiKeyPermissionsTest < ActiveSupport::TestCase + make_my_diffs_pretty! + + should validate_presence_of :scopes + should validate_presence_of :valid_for + + test "validates scopes are known" do + permissions = OIDC::ApiKeyPermissions.new(scopes: ["unknown"]) + permissions.validate + + assert_equal ["unknown scope: unknown"], permissions.errors.messages[:"scopes[0]"] + end + + test "validates scopes are unique" do + permissions = OIDC::ApiKeyPermissions.new(scopes: %w[openid openid]) + permissions.validate + + assert_equal ["must be unique"], permissions.errors.messages[:scopes] + end + + test "validates gems has maximum length of 1" do + permissions = OIDC::ApiKeyPermissions.new(gems: %w[a b]) + permissions.validate + + assert_equal ["may include at most 1 gem"], permissions.errors.messages[:gems] + end +end diff --git a/test/models/oidc/api_key_role_test.rb b/test/models/oidc/api_key_role_test.rb new file mode 100644 index 00000000000..bce620fd32b --- /dev/null +++ b/test/models/oidc/api_key_role_test.rb @@ -0,0 +1,76 @@ +require "test_helper" + +class OIDC::ApiKeyRoleTest < ActiveSupport::TestCase + make_my_diffs_pretty! + + should belong_to :provider + should belong_to :user + should have_many :id_tokens + should validate_presence_of :api_key_permissions + should validate_presence_of :access_policy + should validate_presence_of :name + should validate_length_of(:name).is_at_most(Gemcutter::MAX_FIELD_LENGTH) + + setup do + @role = build(:oidc_api_key_role) + end + + test "inspect with nested attributes" do + assert_match "string_equals", @role.inspect + end + + test "pretty_inspect with nested attributes" do + assert_match "string_equals", @role.pretty_inspect + end + + test "for_rubygem scope" do + user = @role.user + rubygem = create(:rubygem, owners: [user]) + rubygem_role = create(:oidc_api_key_role, api_key_permissions: { gems: [rubygem.name], scopes: ["push_rubygem"] }, user:) + create(:oidc_api_key_role, api_key_permissions: { gems: [create(:rubygem, owners: [user]).name], scopes: ["push_rubygem"] }, user:) + empty_gems = create(:oidc_api_key_role, api_key_permissions: { gems: [], scopes: ["push_rubygem"] }, user:) + nil_gems = create(:oidc_api_key_role, api_key_permissions: { gems: nil, scopes: ["push_rubygem"] }, user:) + + assert_same_elements [rubygem_role], OIDC::ApiKeyRole.for_rubygem(rubygem).to_a + assert_same_elements [@role, empty_gems, nil_gems], OIDC::ApiKeyRole.for_rubygem(nil).to_a + end + + test "for_scope scope" do + role1 = create(:oidc_api_key_role, api_key_permissions: { gems: [], scopes: %w[push_rubygem yank_rubygem] }) + role2 = create(:oidc_api_key_role, api_key_permissions: { gems: [], scopes: ["push_rubygem"] }) + + assert_same_elements [role1, role2], OIDC::ApiKeyRole.for_scope("push_rubygem").to_a + assert_same_elements [role1], OIDC::ApiKeyRole.for_scope("yank_rubygem").to_a + assert_predicate OIDC::ApiKeyRole.for_scope("show_dashboard"), :none? + end + + test "validates gems belong to the user" do + @role.api_key_permissions.gems = ["does_not_exist"] + @role.validate + + assert_equal ["(does_not_exist) does not belong to user #{@role.user.handle}"], @role.errors.messages[:"api_key_permissions.gems[0]"] + end + + test "validates condition claims are known" do + @role.access_policy.statements = [OIDC::AccessPolicy::Statement.new( + effect: "allow", + conditions: [ + { operator: "string_equals", claim: "unknown", value: "" } + ], + principal: { oidc: "" } + )] + @role.validate + + assert_equal ["unknown for the provider"], @role.errors.messages[:"access_policy.statements[0].conditions[0].claim"] + end + + test "validates nested models" do + @role.access_policy.statements = [OIDC::AccessPolicy::Statement.new( + principal: { oidc: nil } + )] + @role.provider = nil + @role.validate + + assert_equal ["can't be blank"], @role.errors.messages[:"access_policy.statements[0].principal.oidc"] + end +end diff --git a/test/models/oidc/id_token_test.rb b/test/models/oidc/id_token_test.rb new file mode 100644 index 00000000000..db89b8a87f8 --- /dev/null +++ b/test/models/oidc/id_token_test.rb @@ -0,0 +1,36 @@ +require "test_helper" + +class OIDC::IdTokenTest < ActiveSupport::TestCase + should belong_to :api_key_role + should belong_to :api_key + should have_one(:provider) + should have_one(:user) + + should validate_presence_of :jwt + + test "validates jti uniqueness" do + api_key_role = FactoryBot.create(:oidc_api_key_role) + id_token = FactoryBot.create(:oidc_id_token, api_key_role:) + assert_raises(ActiveRecord::RecordInvalid) do + FactoryBot.create(:oidc_id_token, provider: id_token.provider, jwt: id_token.jwt, api_key_role:) + end + end + + test "#to_json" do + id_token = FactoryBot.create(:oidc_id_token) + + assert_equal id_token.payload.to_json, id_token.to_json + end + + test "#to_xml" do + id_token = FactoryBot.create(:oidc_id_token) + + assert_equal id_token.payload.to_xml(root: "oidc:id_token"), id_token.to_xml + end + + test "#to_yaml" do + id_token = FactoryBot.create(:oidc_id_token) + + assert_equal id_token.payload.to_yaml, id_token.to_yaml + end +end diff --git a/test/models/oidc/pending_trusted_publisher_test.rb b/test/models/oidc/pending_trusted_publisher_test.rb new file mode 100644 index 00000000000..c1c6a79d603 --- /dev/null +++ b/test/models/oidc/pending_trusted_publisher_test.rb @@ -0,0 +1,29 @@ +require "test_helper" + +class OIDC::PendingTrustedPublisherTest < ActiveSupport::TestCase + setup do + @pending_trusted_publisher = build(:oidc_pending_trusted_publisher) + end + subject { @pending_trusted_publisher } + + should belong_to(:trusted_publisher) + should belong_to(:user) + + should validate_presence_of(:rubygem_name) + should validate_uniqueness_of(:rubygem_name).scoped_to(:trusted_publisher_id, :trusted_publisher_type).case_insensitive + + test "validates rubygem name is available" do + publisher = build(:oidc_pending_trusted_publisher, rubygem_name: "foo") + + assert_predicate publisher, :valid? + + rubygem = create(:rubygem, name: "foo") + + assert_predicate publisher, :valid? + + create(:version, rubygem: rubygem) + + refute_predicate publisher, :valid? + assert_equal ["is already in use"], publisher.errors[:rubygem_name] + end +end diff --git a/test/models/oidc/provider_test.rb b/test/models/oidc/provider_test.rb new file mode 100644 index 00000000000..6b9b023145d --- /dev/null +++ b/test/models/oidc/provider_test.rb @@ -0,0 +1,19 @@ +require "test_helper" + +class OIDC::ProviderTest < ActiveSupport::TestCase + should have_many :api_key_roles + should have_many :id_tokens + should have_many :users + + context "with an issuer that does not match the configuration" do + setup do + @provider = build(:oidc_provider, configuration: { issuer: "https//example.com/other" }) + end + + should "fail to validate" do + refute_predicate @provider, :valid? + assert_equal ["issuer (https//example.com/other) does not match the provider issuer: #{@provider.issuer}"], + @provider.errors.messages[:configuration] + end + end +end diff --git a/test/models/oidc/rubygem_trusted_publisher_test.rb b/test/models/oidc/rubygem_trusted_publisher_test.rb new file mode 100644 index 00000000000..57e3f51c209 --- /dev/null +++ b/test/models/oidc/rubygem_trusted_publisher_test.rb @@ -0,0 +1,13 @@ +require "test_helper" + +class OIDC::RubygemTrustedPublisherTest < ActiveSupport::TestCase + setup do + @rubygem_trusted_publisher = build(:oidc_rubygem_trusted_publisher) + end + subject { @rubygem_trusted_publisher } + + should belong_to(:rubygem) + should belong_to(:trusted_publisher) + + should validate_uniqueness_of(:rubygem).scoped_to(:trusted_publisher_id, :trusted_publisher_type) +end diff --git a/test/models/oidc/trusted_publisher/github_action_test.rb b/test/models/oidc/trusted_publisher/github_action_test.rb new file mode 100644 index 00000000000..bae69432ff9 --- /dev/null +++ b/test/models/oidc/trusted_publisher/github_action_test.rb @@ -0,0 +1,150 @@ +require "test_helper" + +class OIDC::TrustedPublisher::GitHubActionTest < ActiveSupport::TestCase + make_my_diffs_pretty! + + should have_many(:rubygems) + should have_many(:rubygem_trusted_publishers) + should have_many(:api_keys).inverse_of(:owner) + + context "validations" do + setup do + stub_request(:get, Addressable::Template.new("https://api.github.com/users/{user}")) + .to_return(status: 404, body: "", headers: {}) + end + should validate_presence_of(:repository_owner) + should validate_length_of(:repository_owner).is_at_most(Gemcutter::MAX_FIELD_LENGTH) + should validate_presence_of(:repository_name) + should validate_length_of(:repository_name).is_at_most(Gemcutter::MAX_FIELD_LENGTH) + should validate_presence_of(:workflow_filename) + should validate_length_of(:workflow_filename).is_at_most(Gemcutter::MAX_FIELD_LENGTH) + should validate_presence_of(:repository_owner_id) + should validate_length_of(:repository_owner_id).is_at_most(Gemcutter::MAX_FIELD_LENGTH) + + should validate_length_of(:environment).is_at_most(Gemcutter::MAX_FIELD_LENGTH) + end + + test "validates publisher uniqueness" do + publisher = create(:oidc_trusted_publisher_github_action) + assert_raises(ActiveRecord::RecordInvalid) do + create(:oidc_trusted_publisher_github_action, repository_owner: publisher.repository_owner, + repository_name: publisher.repository_name, workflow_filename: publisher.workflow_filename, + repository_owner_id: publisher.repository_owner_id, environment: publisher.environment) + end + end + + test ".for_claims" do + bar_other_owner_id = create(:oidc_trusted_publisher_github_action, repository_name: "bar") + bar_other_owner_id.update!(repository_owner_id: "654321") + bar = create(:oidc_trusted_publisher_github_action, repository_name: "bar") + bar_test = create(:oidc_trusted_publisher_github_action, repository_name: "bar", environment: "test") + _bar_dev = create(:oidc_trusted_publisher_github_action, repository_name: "bar", environment: "dev") + create(:oidc_trusted_publisher_github_action, repository_name: "foo") + + claims = { + repository: "example/bar", + job_workflow_ref: "example/bar/.github/workflows/push_gem.yml@refs/heads/main", + ref: "refs/heads/main", + sha: "04de3558bc5861874a86f8fcd67e516554101e71", + repository_owner_id: "123456" + } + + assert_equal bar, OIDC::TrustedPublisher::GitHubAction.for_claims(claims) + assert_equal bar, OIDC::TrustedPublisher::GitHubAction.for_claims(claims.merge(environment: nil)) + assert_equal bar, OIDC::TrustedPublisher::GitHubAction.for_claims(claims.merge(environment: "other")) + assert_equal bar_test, OIDC::TrustedPublisher::GitHubAction.for_claims(claims.merge(environment: "test")) + end + + test "#name" do + publisher = create(:oidc_trusted_publisher_github_action, repository_name: "bar") + + assert_equal "GitHub Actions example/bar @ .github/workflows/push_gem.yml", publisher.name + + publisher.update!(environment: "test") + + assert_equal "GitHub Actions example/bar @ .github/workflows/push_gem.yml (test)", publisher.name + end + + test "#owns_gem?" do + rubygem1 = create(:rubygem) + rubygem2 = create(:rubygem) + + publisher = create(:oidc_trusted_publisher_github_action) + create(:oidc_rubygem_trusted_publisher, trusted_publisher: publisher, rubygem: rubygem1) + + assert publisher.owns_gem?(rubygem1) + refute publisher.owns_gem?(rubygem2) + end + + test "#to_access_policy" do + publisher = create(:oidc_trusted_publisher_github_action, repository_name: "rubygem1") + + assert_equal( + { + statements: [ + { + effect: "allow", + principal: { + oidc: "https://token.actions.githubusercontent.com" + }, + conditions: [ + { operator: "string_equals", claim: "repository", value: "example/rubygem1" }, + { operator: "string_equals", claim: "repository_owner_id", value: "123456" }, + { operator: "string_equals", claim: "aud", value: Gemcutter::HOST }, + { operator: "string_equals", claim: "job_workflow_ref", value: "example/rubygem1/.github/workflows/push_gem.yml@ref" } + ] + }, + { + effect: "allow", + principal: { + oidc: "https://token.actions.githubusercontent.com" + }, + conditions: [ + { operator: "string_equals", claim: "repository", value: "example/rubygem1" }, + { operator: "string_equals", claim: "repository_owner_id", value: "123456" }, + { operator: "string_equals", claim: "aud", value: Gemcutter::HOST }, + { operator: "string_equals", claim: "job_workflow_ref", value: "example/rubygem1/.github/workflows/push_gem.yml@sha" } + ] + } + ] + }.deep_stringify_keys, + publisher.to_access_policy({ ref: "ref", sha: "sha" }).as_json + ) + + publisher.update!(environment: "test") + + assert_equal( + { + statements: [ + { + effect: "allow", + principal: { + oidc: "https://token.actions.githubusercontent.com" + }, + conditions: [ + { operator: "string_equals", claim: "repository", value: "example/rubygem1" }, + { operator: "string_equals", claim: "environment", value: "test" }, + { operator: "string_equals", claim: "repository_owner_id", value: "123456" }, + { operator: "string_equals", claim: "aud", value: Gemcutter::HOST }, + { operator: "string_equals", claim: "job_workflow_ref", value: "example/rubygem1/.github/workflows/push_gem.yml@ref" } + ] + }, + { + effect: "allow", + principal: { + oidc: "https://token.actions.githubusercontent.com" + }, + conditions: [ + { operator: "string_equals", claim: "repository", value: "example/rubygem1" }, + { operator: "string_equals", claim: "environment", value: "test" }, + { operator: "string_equals", claim: "repository_owner_id", value: "123456" }, + { operator: "string_equals", claim: "aud", value: Gemcutter::HOST }, + { operator: "string_equals", claim: "job_workflow_ref", value: "example/rubygem1/.github/workflows/push_gem.yml@sha" } + ] + } + ] + }.deep_stringify_keys, + publisher.to_access_policy({ ref: "ref", sha: "sha" }).as_json + ) + end +end diff --git a/test/models/organization_test.rb b/test/models/organization_test.rb new file mode 100644 index 00000000000..c8cf1a20f03 --- /dev/null +++ b/test/models/organization_test.rb @@ -0,0 +1,74 @@ +require "test_helper" + +class OrganizationTest < ActiveSupport::TestCase + should have_many(:memberships).dependent(:destroy) + should have_many(:unconfirmed_memberships).dependent(:destroy) + should have_many(:users).through(:memberships) + + # Waiting for Ownerships to be made polymorphic + # + # should have_many(:ownerships).dependent(:destroy) + # should have_many(:unconfirmed_ownerships).dependent(:destroy) + # should have_many(:rubygems).through(:ownerships) + + context "validations" do + context "handle" do + should allow_value("CapsLOCK").for(:handle) + should_not allow_value(nil).for(:handle) + should_not allow_value("1abcde").for(:handle) + should_not allow_value("abc^%def").for(:handle) + should_not allow_value("abc\n@gmail.com") + + refute_predicate user, :valid? + assert_contains user.errors[:unconfirmed_email], "is invalid" + end + end + + context "twitter_username" do + should validate_length_of(:twitter_username).is_at_most(20) + should allow_value("user123_32").for(:twitter_username) + should_not allow_value("@user").for(:twitter_username) + should_not allow_value("user 1").for(:twitter_username) + should_not allow_value("user-1").for(:twitter_username) + should allow_value("01234567890123456789").for(:twitter_username) + should_not allow_value("012345678901234567890").for(:twitter_username) + end + + context "password" do + should "be between 10 and 200 characters" do + user = build(:user, password: "%5a&12ed/") + + refute_predicate user, :valid? + assert_contains user.errors[:password], "is too short (minimum is 10 characters)" + + user.password = "#{'a8b5d2d451' * 20}a" + + refute_predicate user, :valid? + assert_contains user.errors[:password], "is too long (maximum is 200 characters)" + + user.password = "633!cdf7b3426c9%f6dd1a0b62d4ce44c4f544e%" + user.valid? + + assert_nil user.errors[:password].first + end + + should "be invalid when an empty string" do + user = build(:user, password: "") + + refute_predicate user, :valid? + end + + should "be invalid when it's found in a data breach" do + user = build(:user, password: "1234567890") + + refute_predicate user, :valid? + assert_contains user.errors[:password], "has previously appeared in a data breach and should not be used" + end + end + end + + context "with a user" do + setup do + @user = create(:user) + end + + should "authenticate with email/password" do + assert_equal @user, User.authenticate(@user.email, @user.password) + end + + should "authenticate with handle/password" do + assert_equal @user, User.authenticate(@user.handle, @user.password) + end + + should "not authenticate with bad handle, good password" do + assert_nil User.authenticate("bad", @user.password) + end + + should "not authenticate with bad email, good password" do + assert_nil User.authenticate("bad@example.com", @user.password) + end + + should "not authenticate with good email, bad password" do + assert_nil User.authenticate(@user.email, "bad") + end + + should "have handle on JSON" do + json = JSON.parse(@user.to_json) + hash = { "id" => @user.id, "handle" => @user.handle } + + assert_equal hash, json + end + + should "have handle on XML" do + xml = Nokogiri.parse(@user.to_xml) + + assert_equal "user", xml.root.name + assert_equal %w[id handle], xml.root.children.select(&:element?).map(&:name) + end + + should "have handle on YAML" do + yaml = YAML.safe_load(@user.to_yaml) + hash = { "id" => @user.id, "handle" => @user.handle } + + assert_equal hash, yaml + end + + should "create api key" do + assert_not_nil @user.api_key + end + + should "give user if specified name is user handle or email" do + assert_not_nil User.find_by_name(@user.handle) + assert_equal User.find_by_name(@user.handle), User.find_by_name(@user.handle) + end + + should "give email if handle is not set for name" do + @user.handle = nil + + assert_nil @user.handle + assert_equal @user.email, @user.name + end + + should "give handle if handle is set for name" do + @user.handle = "qrush" + @user.save + + assert_equal @user.handle, @user.name + end + + should "setup a field to toggle showing email with default falsš" do + refute_predicate @user, :public_email? + end + + should "only return rubygems" do + my_rubygem = create(:rubygem) + create(:ownership, user: @user, rubygem: my_rubygem) + + assert_equal [my_rubygem], @user.rubygems + end + + context "generate_confirmation_token" do + should "set confirmation token and token_expires_at" do + assert_changed(@user, :confirmation_token, :token_expires_at) do + @user.generate_confirmation_token + @user.save! + end + end + + context "when user has an unconfirmed email" do + setup do + @user.update(unconfirmed_email: "unconfirmed@example.com") + end + + should "delete the unconfirmed email by default" do + assert_changed(@user, :unconfirmed_email, :confirmation_token, :token_expires_at) do + @user.generate_confirmation_token + @user.save! + end + + assert_nil @user.unconfirmed_email + end + + should "not delete unconfirmed email when reset_unconfirmed_email is false" do + assert_changed(@user, :confirmation_token, :token_expires_at) do + @user.generate_confirmation_token(reset_unconfirmed_email: false) + @user.save! + end + + assert_equal "unconfirmed@example.com", @user.unconfirmed_email + end + end + + should "generate a sufficiently long token" do + @user.generate_confirmation_token + @user.save! + + assert_operator @user.confirmation_token.length, :>=, 24, "Token must be at least 24 characters long" + end + end + + context "unconfirmed_email update" do + should "set confirmation token and token_expires_at" do + assert_changed(@user, :confirmation_token, :token_expires_at) do + @user.update(unconfirmed_email: "some@one.com") + end + end + end + + context "with subscribed gems" do + setup do + @subscribed_gem = create(:rubygem) + @unsubscribed_gem = create(:rubygem) + create(:subscription, user: @user, rubygem: @subscribed_gem) + end + + should "only fetch the subscribed gems with #subscribed_gems" do + assert_contains @user.subscribed_gems, @subscribed_gem + assert_does_not_contain @user.subscribed_gems, @unsubscribed_gem + end + end + + should "have all gems and specific gems for hooks" do + rubygem = create(:rubygem) + rubygem_hook = create(:web_hook, user: @user, rubygem: rubygem) + global_hook = create(:global_web_hook, user: @user) + all_hooks = @user.all_hooks + + assert_equal rubygem_hook, all_hooks[rubygem.name].first + assert_equal global_hook, all_hooks["all gems"].first + end + + should "have all gems for hooks" do + global_hook = create(:global_web_hook, user: @user) + all_hooks = @user.all_hooks + + assert_equal global_hook, all_hooks["all gems"].first + assert_equal 1, all_hooks.keys.size + end + + should "have only specific for hooks" do + rubygem = create(:rubygem) + rubygem_hook = create(:web_hook, user: @user, rubygem: rubygem) + all_hooks = @user.all_hooks + + assert_equal rubygem_hook, all_hooks[rubygem.name].first + assert_equal 1, all_hooks.keys.size + end + + context "#valid_confirmation_token?" do + should "return false when email confirmation token has expired" do + @user.update_attribute(:token_expires_at, 2.minutes.ago) + + refute_predicate @user, :valid_confirmation_token? + end + + should "reutrn true when email confirmation token has not expired" do + two_minutes_in_future = 2.minutes.from_now + @user.update_attribute(:token_expires_at, two_minutes_in_future) + + assert_predicate @user, :valid_confirmation_token? + end + end + + context "two factor authentication" do + should "disable mfa by default" do + refute_predicate @user, :mfa_enabled? + end + + context "when enabled" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + end + + should "be able to use a recovery code only once" do + code = @user.new_mfa_recovery_codes.first + + assert @user.ui_mfa_verified?(code) + refute @user.ui_mfa_verified?(code) + end + + should "be able to use mfa recovery codes out of order" do + @user.new_mfa_recovery_codes.reverse_each do |code| + assert @user.ui_mfa_verified?(code) + end + + assert_empty @user.reload.mfa_hashed_recovery_codes + end + + should "be able to verify correct OTP" do + assert @user.ui_mfa_verified?(ROTP::TOTP.new(@user.totp_seed).now) + end + + should "return true for mfa status check" do + assert_predicate @user, :mfa_enabled? + refute_predicate @user, :mfa_disabled? + end + + should "return true for otp in last interval" do + last_otp = ROTP::TOTP.new(@user.totp_seed).at(Time.current - 30) + + assert @user.ui_mfa_verified?(last_otp) + end + + should "return true for otp in next interval" do + next_otp = ROTP::TOTP.new(@user.totp_seed).at(Time.current + 30) + + assert @user.ui_mfa_verified?(next_otp) + end + + context "blocking user with api key" do + setup do + api_key = create(:api_key, owner: @user) + # simulate gem pushed using api key to ensure + # user with pushed gems can be blocked + create(:version, pusher: @user, pusher_api_key: api_key) + end + + should "reset email and mfa" do + assert_changed(@user, :email, :password, :api_key, :totp_seed, :remember_token) do + @user.block! + end + + assert @user.email.start_with?("security+locked-") + assert @user.email.end_with?("@rubygems.org") + assert_empty @user.mfa_hashed_recovery_codes + assert_predicate @user, :mfa_disabled? + end + + should "reset api key" do + @user.block! + + assert_nil @user.api_key + assert_predicate @user.api_keys, :any? + @user.api_keys.each do |api_key| + assert_predicate api_key, :expired? + end + end + end + end + + context "when disabled" do + setup do + @user.disable_totp! + end + + should "return false for verifying OTP" do + refute @user.ui_mfa_verified?("") + end + + should "return false for mfa status check" do + refute_predicate @user, :mfa_enabled? + assert_predicate @user, :mfa_disabled? + end + end + end + + context "recommend or require mfa from downloads" do + setup do + @rubygem = create(:rubygem) + create(:ownership, user: @user, rubygem: @rubygem) + + assert_equal [@rubygem], @user.rubygems + end + + context "when a user doesn't own a gem with more downloads than the recommended threshold" do + setup do + GemDownload.increment( + Rubygem::MFA_RECOMMENDED_THRESHOLD, + rubygem_id: @rubygem.id + ) + end + + should "return false for mfa_recommended_not_yet_enabled?" do + refute_predicate @user, :mfa_recommended_not_yet_enabled? + end + + should "return false for mfa_recommended_weak_level_enabled?" do + refute_predicate @user, :mfa_recommended_weak_level_enabled? + end + + should "return false for mfa_required?" do + refute_predicate @user, :mfa_required? + end + end + + context "when mfa disabled user owns a gem with more downloads than the recommended threshold but less than the required threshold" do + setup do + GemDownload.increment( + Rubygem::MFA_RECOMMENDED_THRESHOLD + 1, + rubygem_id: @rubygem.id + ) + end + + should "return true for mfa_recommended_not_yet_enabled?" do + assert_predicate @user, :mfa_recommended_not_yet_enabled? + end + + should "return false for mfa_recommended_weak_level_enabled?" do + refute_predicate @user, :mfa_recommended_weak_level_enabled? + end + + should "return false for mfa_required?" do + refute_predicate @user, :mfa_required? + end + end + + context "when mfa disabled user owns a gem with more downloads than the required threshold" do + setup do + GemDownload.increment( + Rubygem::MFA_REQUIRED_THRESHOLD + 1, + rubygem_id: @rubygem.id + ) + end + + should "return false for mfa_recommended?" do + refute_predicate @user, :mfa_recommended? + end + + should "return true for mfa_required?" do + assert_predicate @user, :mfa_required? + end + end + + context "when mfa `ui_only` user owns a gem with more downloads than the recommended threshold but less than the required threshold" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + + GemDownload.increment( + Rubygem::MFA_RECOMMENDED_THRESHOLD + 1, + rubygem_id: @rubygem.id + ) + end + + should "return false for mfa_recommended_not_yet_enabled?" do + refute_predicate @user, :mfa_recommended_not_yet_enabled? + end + + should "return true for mfa_recommended_weak_level_enabled?" do + assert_predicate @user, :mfa_recommended_weak_level_enabled? + end + + should "return false for mfa_required?" do + refute_predicate @user, :mfa_required? + end + end + + context "when mfa `ui_only` user owns a gem with more downloads than the required threshold" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + + GemDownload.increment( + Rubygem::MFA_REQUIRED_THRESHOLD + 1, + rubygem_id: @rubygem.id + ) + end + + should "return false for mfa_recommended?" do + refute_predicate @user, :mfa_recommended? + end + + should "return true for mfa_required?" do + assert_predicate @user, :mfa_required? + end + end + + context "when strong user owns a gem with more downloads than the recommended threshold but less than the required threshold" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + + GemDownload.increment( + Rubygem::MFA_RECOMMENDED_THRESHOLD + 1, + rubygem_id: @rubygem.id + ) + end + + should "return false for mfa_recommended_not_yet_enabled?" do + refute_predicate @user, :mfa_recommended_not_yet_enabled? + end + + should "return false for mfa_recommended_weak_level_enabled?" do + refute_predicate @user, :mfa_recommended_weak_level_enabled? + end + + should "return false for mfa_required?" do + refute_predicate @user, :mfa_required? + end + end + + context "when strong user owns a gem with more downloads than the required threshold" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + + GemDownload.increment( + Rubygem::MFA_REQUIRED_THRESHOLD + 1, + rubygem_id: @rubygem.id + ) + end + + should "return false for mfa_recommended?" do + refute_predicate @user, :mfa_recommended? + end + + should "return false for mfa_required?" do + refute_predicate @user, :mfa_required? + end + end + end + end + + context ".without_mfa" do + setup do + create(:user, handle: "has_mfa").enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + create(:user, handle: "no_mfa") + end + + should "return only users without mfa" do + users_without_mfa = User.without_mfa + + assert_equal 1, users_without_mfa.size + assert_equal "no_mfa", users_without_mfa.first.handle + end + end + + context "rubygems" do + setup do + @user = create(:user) + @rubygems = [2000, 1000, 3000].map do |download| + create(:rubygem, downloads: download).tap do |rubygem| + create(:ownership, rubygem: rubygem, user: @user) + create(:version, rubygem: rubygem) + end + end + end + + should "sort by downloads method" do + assert_equal @rubygems.values_at(2, 0, 1), @user.rubygems_downloaded + end + + should "not include gem if all versions have been yanked" do + @rubygems.first.versions.first.update! indexed: false + + assert_equal 2, @user.rubygems_downloaded.count + end + + should "total their number of pushed rubygems except yanked gems" do + @rubygems.first.versions.first.update! indexed: false + + assert_equal(2, @user.total_rubygems_count) + end + + should "not include gems with more than one owner" do + create(:ownership, rubygem: @rubygems.first) + + assert_equal 2, @user.only_owner_gems.count + end + end + + context "yaml" do + setup do + @user = create(:user) + end + + should "return its payload" do + assert_equal @user.payload, YAML.safe_load(@user.to_yaml) + end + + should "nest properly" do + assert_equal [@user.payload], YAML.safe_load([@user].to_yaml) + end + end + + context "destroy" do + setup do + @user = create(:user) + @rubygem = create(:rubygem) + create(:ownership, rubygem: @rubygem, user: @user) + @version1 = create(:version, rubygem: @rubygem) + @version2 = create(:version, rubygem: @rubygem) + GemDownload.increment(100_001, rubygem_id: @rubygem.id, version_id: @version1.id) + end + + context "user is only owner of gem" do + should "record deletion" do + assert_difference "Deletion.count", 1 do + @user.destroy + end + end + should "preserve ineligible deletion version" do + @user.destroy + + assert_predicate @version1.reload, :indexed? + + assert_event Events::RubygemEvent::VERSION_YANK_FORBIDDEN, { + number: @version1.number, + platform: "ruby", + yanked_by: @user.handle, + version_gid: @version1.to_gid_param, + actor_gid: @user.to_gid.to_s, + reason: "Versions with more than 100,000 downloads cannot be deleted." + }, @rubygem.reload.events.where(tag: Events::RubygemEvent::VERSION_YANK_FORBIDDEN).sole + end + should "mark rubygem unowned" do + @user.destroy + + assert_predicate @rubygem, :unowned? + end + end + + context "user has co-owner of gem" do + setup do + create(:ownership, rubygem: @rubygem, user: create(:user)) + end + + should "not record deletion" do + assert_no_difference "Deletion.count" do + @user.destroy + end + end + should "not mark rubygem unowned" do + @user.destroy + + refute_predicate @rubygem, :unowned? + end + end + end + + context "#remember_me!" do + setup do + @user = create(:user) + @user.remember_me! + end + + should "set remember_token" do + assert_not_nil @user.remember_token + end + + should "set expiry of remember_token to two weeks from now" do + expected_expiry = Gemcutter::REMEMBER_FOR.from_now + + assert_in_delta expected_expiry, @user.remember_token_expires_at, 1.second + end + end + + context "#remember_me?" do + setup { @user = create(:user) } + + should "return false when remember_token_expires_at is not set" do + refute_predicate @user, :remember_me? + end + + should "return false when remember_token has expired" do + @user.update_attribute(:remember_token_expires_at, 1.second.ago) + + refute_predicate @user, :remember_me? + end + + should "return true when remember_token has not expired" do + @user.update_attribute(:remember_token_expires_at, 1.second.from_now) + + assert_predicate @user, :remember_me? + end + end + + context ".find_by_slug" do + setup do + @user = create(:user, handle: "findable") + @nohandle = create(:user, handle: nil) + end + + should "return nil if using a falsy value" do + refute User.find_by_slug(nil) + refute User.find_by_slug("") + refute User.find_by_slug(" ") + end + + should "return an user when founded by id" do + assert_equal User.find_by_slug(@user.id), @user + end + + should "return an user when founded by handle" do + assert_equal User.find_by_slug(@user.handle), @user + end + + should "return nil when using id" do + refute User.find_by_slug(-9999) + end + + should "return nil when not founded by handle" do + refute User.find_by_slug("notfoundable") + end + end + + context ".find_by_slug!" do + setup do + @dorian = create(:user, handle: "dorianmariefr") + @nohandle = create(:user, handle: nil) + end + + should "return an user if the slug matches" do + assert_equal @dorian, User.find_by_slug!("dorianmariefr") + end + + should "raise error if not found" do + assert_raises ActiveRecord::RecordNotFound do + User.find_by_slug!(SecureRandom.hex) + end + end + + should "be able to find by id" do + assert_equal @dorian, User.find_by_slug!(@dorian.id) + assert_equal @nohandle, User.find_by_slug!(@nohandle.id) + end + + should "not return an user with nil handle if searching for nil" do + assert_raises ActiveRecord::RecordNotFound do + User.find_by_slug!(nil) + end + end + + should "not return an user with nil handle if searching for blank" do + assert_raises ActiveRecord::RecordNotFound do + User.find_by_slug!("") + end + end + end + + context ".find_by_name" do + setup do + @dorian = create(:user, handle: "dorianmariefr") + @nohandle = create(:user, handle: nil) + end + + should "return an user if the slug matches" do + assert_equal @dorian, User.find_by_name("dorianmariefr") + end + + should "raise error if not found" do + assert_nil User.find_by_name(SecureRandom.hex) + end + + should "not return an user with nil handle if searching for nil" do + assert_nil User.find_by_name(nil) + end + + should "not return an user with nil handle if searching for blank" do + assert_nil User.find_by_name("") + end + end + + context ".find_by_blocked" do + setup do + @dorian = create(:user, handle: "dorianmariefr") + @nohandle = create(:user, handle: nil) + end + + should "return an user if the slug matches" do + assert_equal @dorian, User.find_by_blocked("dorianmariefr") + end + + should "raise error if not found" do + assert_nil User.find_by_blocked(SecureRandom.hex) + end + + should "not return an user with nil handle if searching for nil" do + assert_nil User.find_by_blocked(nil) + end + + should "not return an user with nil handle if searching for blank" do + assert_nil User.find_by_blocked("") + end + end + + context "block when handle has uppercase" do + setup { @user = create(:user, handle: "MikeJudge") } + + should "not raise ActiveRecord::RecordInvalid for email address already taken" do + assert_changed(@user, :email, :password, :api_key, :remember_token) do + @user.block! + end + end + end + + context "block invalid legacy user" do + setup do + @user = create(:user, handle: "MikeJudge") + @api_key = create(:api_key, owner: @user) + + # simulate legacy invalid api key + @api_key.update_columns(scopes: @api_key.scopes + %i[show_dashboard add_owner]) + + refute_predicate @api_key, :valid? + end + + should "block user anyway" do + assert_changed(@user, :email, :password, :api_key, :remember_token) do + @user.block! + end + end + end + + context "#unblock!" do + setup do + @user = create(:user, :blocked) + @original_email = @user.blocked_email + @user.unblock! + end + + should "restore the email field" do + assert_equal @original_email, @user.email + end + + should "make the blocked email field nil" do + assert_nil @user.blocked_email + end + + context "when the user is not currently blocked" do + setup do + @user = create(:user) + end + + should "raise an error" do + assert_raises(ArgumentError) do + @user.unblock! + end + end + end + end + + context "#blocked?" do + setup do + @blocked_user = build(:user, :blocked) + @unblocked_user = build(:user) + end + + should "be true when the user has a blocked email" do + assert_predicate @blocked_user, :blocked? + end + + should "be false when the user does not have a blocked email" do + refute_predicate @unblocked_user, :blocked? + end + end + + context ".normalize_email" do + should "return the normalized email" do + assert_equal "UsEr@example.COM", User.normalize_email(:"UsEr@\texample . COM") + end + + should "preserve valid characters so that the format error can be returned" do + # UTF-8 "香" character, which is valid UTF-8, but we reject utf-8 in email addresses + assert_equal "香@example.com", User.normalize_email("\u9999@example.com".force_encoding("utf-8")) + + # ISO-8859-1 "Å" character (valid in ISO-8859-1) + encoded_email = "myem\xC5il@example.com".force_encoding("ISO-8859-1") + + assert_equal encoded_email, User.normalize_email(encoded_email) + end + + should "return an empty string on invalid inputs" do + # bad encoding when sent as ASCII-8BIT + assert_equal "", User.normalize_email("\u9999@example.com".force_encoding("ascii")) + + # ISO-8859-1 "Å" character (invalid in UTF-8, which uses \xC385 for this character) + assert_equal "", User.normalize_email("myem\xC5il@example.com".force_encoding("UTF-8")) + end + + should "return an empty string for nil" do + assert_equal "", User.normalize_email(nil) + end + end +end diff --git a/test/models/version_manifest_test.rb b/test/models/version_manifest_test.rb new file mode 100644 index 00000000000..8b89032ced0 --- /dev/null +++ b/test/models/version_manifest_test.rb @@ -0,0 +1,317 @@ +require "test_helper" + +class VersionsManifestTest < ActiveSupport::TestCase + def create_entry(path, sha256, body: rand.to_s, mime: "text/plain") + RubygemContents::Entry.new(path: path, sha256: sha256, body: body, mime: mime, size: body.size) + end + + setup do + RubygemFs.mock! + @manifest = VersionManifest.new(number: "0.1.0", gem: "gemname") + @manifest2 = VersionManifest.new(number: "0.2.0", gem: "gemname") + @manifest3 = VersionManifest.new(number: "0.2.0", platform: "platform", gem: "gemname") + + @files = [ + create_entry("path1", "hex1-A"), + create_entry("path2", "hex2-A"), + create_entry("path3", "hex3-A"), + create_entry("path4", "hex3-A") + ] + @spec = stub(to_ruby: "#{@manifest.gem}-#{@manifest.version}.gemspec") + end + + teardown do + RubygemFs.mock! + end + + context "#checksums_root" do + should "return a path" do + assert_equal "gems/gemname/checksums/", @manifest.checksums_root + end + end + + context "#checksums_key" do + should "return a path" do + assert_equal "gems/gemname/checksums/0.1.0.sha256", @manifest.checksums_key + assert_equal "gems/gemname/checksums/0.2.0.sha256", @manifest2.checksums_key + end + + should "return a path with a platform" do + assert_equal "gems/gemname/checksums/0.2.0-platform.sha256", @manifest3.checksums_key + end + end + + context "#path_root" do + should "return a path" do + assert_equal "gems/gemname/paths/0.1.0/", @manifest.path_root + assert_equal "gems/gemname/paths/0.2.0/", @manifest2.path_root + end + + should "return a path with a platform" do + assert_equal "gems/gemname/paths/0.2.0-platform/", @manifest3.path_root + end + end + + context "#path_key" do + should "return a path" do + assert_equal "gems/gemname/paths/0.1.0/path1", @manifest.path_key("path1") + assert_equal "gems/gemname/paths/0.2.0/lib/to/path.rb", @manifest2.path_key("lib/to/path.rb") + end + + should "return a path with a platform" do + assert_equal "gems/gemname/paths/0.2.0-platform/lib/to/path.rb", @manifest3.path_key("lib/to/path.rb") + end + end + + context "#spec_key" do + should "return a path" do + assert_equal "gems/gemname/specs/gemname-0.1.0.gemspec", @manifest.spec_key + assert_equal "gems/gemname/specs/gemname-0.2.0.gemspec", @manifest2.spec_key + end + + should "return a path with a platform" do + assert_equal "gems/gemname/specs/gemname-0.2.0-platform.gemspec", @manifest3.spec_key + end + end + + context "#spec" do + should "return nil for missing version" do + assert_nil @manifest.spec + end + + should "return the spec" do + @manifest.store_spec @spec + + assert_equal @spec.to_ruby, @manifest.spec + end + end + + context "#entry" do + should "return nil for a nil path" do + assert_nil @manifest.entry(nil) + end + + should "return nil for a non-indexed file" do + assert_nil @manifest.entry("nope.rb") + end + + should "return nil for a file not on this version" do + @manifest2.store_entry(create_entry("path", "hex")) + + assert_nil @manifest.entry("path") + end + + should "return an entry for an indexed file" do + @manifest.store_entries(@files) + + assert_equal "hex1-A", @manifest.entry("path1").fingerprint + assert_equal "hex2-A", @manifest.entry("path2").fingerprint + assert_equal "hex3-A", @manifest.entry("path3").fingerprint + assert_equal "hex3-A", @manifest.entry("path4").fingerprint + end + end + + context "#paths" do + should "return empty Array for missing version" do + assert_empty VersionManifest.new(gem: "nope", number: "0.1.0").paths + end + + should "have the same files indexed as entered" do + @manifest.store_entries(@files) + + assert_equal %w[path1 path2 path3 path4], @manifest.paths, "manifest files should match" + end + end + + context "#checksums" do + should "return empty checksums for missing version" do + assert_empty VersionManifest.new(gem: "nope", number: "0.1.0").checksums.values + end + + should "return checksums for version" do + @manifest.store_entries(@files) + + assert_equal %w[hex1-A hex2-A hex3-A], @manifest.checksums.values.uniq.sort, "manifest checksums should match" + end + end + + context "#checksums_file" do + should "return empty checksums for missing version" do + assert_nil VersionManifest.new(gem: "nope", number: "0.1.0").checksums_file + end + + should "return checksums for version" do + @manifest.store_entries(@files) + + assert_equal <<~CHECKSUMS, @manifest.checksums_file + hex1-A path1 + hex2-A path2 + hex3-A path3 + hex3-A path4 + CHECKSUMS + end + end + + context "#store_package" do + should "raise if the package is nil" do + manifest = VersionManifest.new(gem: "test", number: "0.1.0") + + assert_raises(ArgumentError) do + manifest.store_package(nil) + end + end + + should "store the package" do + gem = gem_file + package = Gem::Package.new(gem) + @manifest.store_package(package) + + assert_equal package.spec.to_ruby, @manifest.spec + assert_equal package.contents.sort, @manifest.paths.sort + ensure + gem&.close + end + end + + context "#store_entries" do + should "handle empty entries" do + @manifest.store_entries [] + + assert_empty @manifest.paths + end + + should "store the entries" do + @manifest.store_entries(@files) + + assert_equal @files.map(&:path).sort, @manifest.paths.sort + assert_equal @files.to_h { |e| [e.path, e.sha256] }, @manifest.checksums + end + end + + context "#store_spec" do + should "store the spec" do + @manifest.store_spec(@spec) + + assert_equal @spec.to_ruby, @manifest.spec + end + end + + context "#yank" do + setup do + @manifest.store_entries(@files) + @manifest.store_spec(@spec) + + @files2 = [ + create_entry("path1", "hex1-B"), # path1 changes every version + create_entry("path2", "hex2-B"), # path2 changes here, not on the 3rd + create_entry("path3", "hex3-A"), # path3 never changes + create_entry("path4", "hex3-A") # path4 is the same file as path3, until manifest3 + ] + @manifest2.store_entries(@files2) + @spec2 = stub(to_ruby: "#{@manifest2.gem}-#{@manifest2.version}.gemspec") + @manifest2.store_spec(@spec2) + + @files3 = [ + create_entry("path1", "hex1-C"), # path1 changes every version + create_entry("path2", "hex2-B"), # path2 remains unchanged from v2 + create_entry("path3", "hex3-A"), # path3 never changes + create_entry("path4", "hex4-A"), # path4 changes to a unique file in v3 + create_entry("path5", "hex5-A") # path5 is a new file on the third version + ] + @manifest3.store_entries(@files3) + @spec3 = stub(to_ruby: "#{@manifest3.gem}-#{@manifest3.version}.gemspec") + @manifest3.store_spec(@spec3) + end + + should "delete all path files for a version" do + @manifest2.yank + + assert_empty @manifest2.paths + @files2.each do |entry| + assert_nil @manifest2.entry(entry.path), "path #{entry.path} should be deleted" + end + end + + should "delete all content files unique to the yanked version" do + @manifest2.yank + + assert_equal %w[hex1-A hex2-A hex3-A], @manifest.checksums.values.uniq.sort + assert_equal %w[hex1-C hex2-B hex3-A hex4-A hex5-A], @manifest3.checksums.values.uniq.sort + assert_equal %w[hex1-A hex1-C hex2-A hex2-B hex3-A hex4-A hex5-A], @manifest.contents.keys + end + + should "fall back to a full contents scan if the checksums file is missing (partial upload)" do + @manifest2.fs.remove(@manifest2.checksums_key) + + @manifest2.yank + + assert_equal %w[hex1-A hex2-A hex3-A], @manifest.checksums.values.uniq.sort + assert_equal %w[hex1-C hex2-B hex3-A hex4-A hex5-A], @manifest3.checksums.values.uniq.sort + assert_equal %w[hex1-A hex1-C hex2-A hex2-B hex3-A hex4-A hex5-A], @manifest.contents.keys + end + + should "delete all paths for the yanked version" do + @manifest3.yank + + refute_empty @manifest.paths + refute_empty @manifest2.paths + assert_empty @manifest3.paths + end + + should "delete the spec for the yanked version" do + @manifest.yank + + refute @manifest.spec + assert @manifest2.spec + assert @manifest3.spec + end + + should "delete all files when all versions are yanked" do + @manifest.yank + @manifest2.yank + @manifest3.yank + + assert_empty @manifest.checksums + assert_empty @manifest2.checksums + assert_empty @manifest3.checksums + + assert_empty @manifest.contents.keys + + assert_empty @manifest.paths + assert_empty @manifest2.paths + assert_empty @manifest3.paths + + assert_nil @manifest.spec + assert_nil @manifest2.spec + assert_nil @manifest3.spec + end + + should "gracefully no-op when there's nothing to delete" do + assert @manifest.yank + + assert @manifest.yank + end + end + + context "#==" do + should "return true for the same gem and number" do + assert_equal @manifest, VersionManifest.new(gem: "gemname", number: "0.1.0") + end + + should "return true for the same gem and number and platform" do + assert_equal @manifest3, VersionManifest.new(gem: "gemname", number: "0.2.0", platform: "platform") + end + + should "return false for the same gem and number with different platform" do + refute_equal @manifest, VersionManifest.new(gem: "gemname", number: "0.1.0", platform: "platform") + refute_equal @manifest3, VersionManifest.new(gem: "gemname", number: "0.2.0", platform: "other") + refute_equal @manifest, @manifest2 + refute_equal @manifest, @manifest3 + end + + should "return false for the different gem and matching version" do + refute_equal @manifest, VersionManifest.new(gem: "other", number: "0.1.0") + refute_equal @manifest3, VersionManifest.new(gem: "other", number: "0.2.0", platform: "platform") + end + end +end diff --git a/test/unit/version_test.rb b/test/models/version_test.rb similarity index 76% rename from test/unit/version_test.rb rename to test/models/version_test.rb index 4b2832d850b..98b9194785c 100644 --- a/test/unit/version_test.rb +++ b/test/models/version_test.rb @@ -14,11 +14,12 @@ class VersionTest < ActiveSupport::TestCase fields = %w[number built_at summary description authors platform ruby_version rubygems_version prerelease downloads_count licenses - requirements sha metadata created_at] - assert_equal fields.map(&:to_s).sort, json.keys.sort + requirements sha spec_sha metadata created_at] + + assert_equal fields.sort, json.keys.sort assert_equal @version.authors, json["authors"] - assert_equal @version.built_at, json["built_at"] - assert_equal @version.created_at, json["created_at"] + assert_equal @version.built_at.as_json, json["built_at"] + assert_equal @version.created_at.as_json, json["created_at"] assert_equal @version.description, json["description"] assert_equal @version.downloads_count, json["downloads_count"] assert_equal @version.metadata, json["metadata"] @@ -42,7 +43,8 @@ class VersionTest < ActiveSupport::TestCase xml = Nokogiri.parse(@version.to_xml) fields = %w[number built-at summary description authors platform ruby-version rubygems-version prerelease downloads-count licenses - requirements sha metadata created-at] + requirements sha spec-sha metadata created-at] + assert_equal fields.map(&:to_s).sort, xml.root.children.map(&:name).reject { |t| t == "text" }.sort assert_equal @version.authors, xml.at_css("authors").content @@ -75,7 +77,7 @@ class VersionTest < ActiveSupport::TestCase @most_recent = create(:version, rubygem: @gem, number: "0.2", platform: "universal-rubinius") create(:version, rubygem: @gem, number: "0.1", platform: "mswin32") - assert_equal @most_recent, Version.most_recent + assert_equal @most_recent, @gem.most_recent_version end end @@ -107,11 +109,11 @@ class VersionTest < ActiveSupport::TestCase assert_equal 3, version_list.size - assert version_list.include?(@version_one_latest) - assert version_list.include?(@version_two_earlier) - assert version_list.include?(@version_three) - refute version_list.include?(@version_one_earlier) - refute version_list.include?(@version_two_latest) + assert_includes version_list, @version_one_latest + assert_includes version_list, @version_two_earlier + assert_includes version_list, @version_three + refute_includes version_list, @version_one_earlier + refute_includes version_list, @version_two_latest end end @@ -132,6 +134,7 @@ class VersionTest < ActiveSupport::TestCase should "order gems by created at and show only gems that have more than one version" do versions = Version.just_updated + assert_equal 4, versions.size assert_equal [@first, @second, @third, @fourth], versions end @@ -151,14 +154,15 @@ class VersionTest < ActiveSupport::TestCase assert @version.save assert @number_version.save assert @platform_version.save - refute @dup_version.valid? + refute_predicate @dup_version, :valid? end should "be able to find dependencies" do @dependency = create(:rubygem) @version = build(:version, rubygem: @rubygem, number: "1.0.0", platform: "ruby") @version.dependencies << create(:dependency, version: @version, rubygem: @dependency) - refute Version.first.dependencies.empty? + + refute_empty Version.first.dependencies end should "sort dependencies alphabetically" do @@ -178,9 +182,9 @@ class VersionTest < ActiveSupport::TestCase version: @version, rubygem: @first_dependency_by_alpha) - assert @first_dependency_by_alpha.name, @version.dependencies.first.name - assert @second_dependency_by_alpha.name, @version.dependencies[1].name - assert @third_dependency_by_alpha.name, @version.dependencies.last.name + assert_equal @first_dependency_by_alpha.name, @version.dependencies.first.name + assert_equal @second_dependency_by_alpha.name, @version.dependencies[1].name + assert_equal @third_dependency_by_alpha.name, @version.dependencies.last.name end end @@ -190,16 +194,67 @@ class VersionTest < ActiveSupport::TestCase @version = build(:version) end + context "#rely_on_built_at?" do + setup do + created_at = Date.parse("2009-07-25") + built_at = created_at - 30.days + @version.update(created_at: created_at, built_at: built_at) + end + + should "return true created_at date is equal 2009-07-25 and built_at is present and not higher than created_at" do + assert_predicate @version, :rely_on_built_at? + end + + should "return false if built_at is not set" do + @version.update(built_at: nil) + + refute_predicate @version, :rely_on_built_at? + end + + should "return false if created_at is not 2009-07-25" do + @version.update(created_at: Date.parse("2020-01-01")) + + refute_predicate @version, :rely_on_built_at? + end + + should "return false if built_at is higher than 2009-07-25" do + @version.update(built_at: Date.parse("2020-01-01")) + + refute_predicate @version, :rely_on_built_at? + end + end + + context "#authored_at" do + setup do + @built_at = Version::RUBYGEMS_IMPORT_DATE - 60.days + @version.update(built_at: @built_at, created_at: Version::RUBYGEMS_IMPORT_DATE) + end + + should "return built_at as if #rely_on_built_at? returns true" do + assert_equal @built_at, @version.authored_at + end + + should "return created_at if #rely_on_built_at? returns false" do + created_at = Version::RUBYGEMS_IMPORT_DATE + 1.day + @version.update(created_at: created_at) + + assert_equal created_at, @version.authored_at + end + end + should "have a rubygems version" do @version.update(required_rubygems_version: @required_rubygems_version) new_version = Version.find(@version.id) + assert_equal new_version.required_rubygems_version, @required_rubygems_version end should "limit the character length" do - @version.required_rubygems_version = format(">=%s", "0" * 2 * 1024 * 1024 * 100) + @version.required_rubygems_version = format(">=%s", "0" * 1024) @version.validate - assert_equal @version.errors.messages[:required_rubygems_version], ["is too long (maximum is 255 characters)"] + + assert_equal(["is too long (maximum is 255 characters)", "must be list of valid requirements"], + @version.errors.messages[:required_rubygems_version]) end end @@ -212,12 +267,13 @@ class VersionTest < ActiveSupport::TestCase @version.required_rubygems_version = "" @version.validate - assert_equal @version.errors.messages[:required_rubygems_version], [] + assert_empty(@version.errors.messages[:required_rubygems_version]) end should "not have a rubygems version" do @version.update(required_rubygems_version: nil) nil_version = Version.find(@version.id) + assert_nil nil_version.required_rubygems_version end end @@ -231,7 +287,7 @@ class VersionTest < ActiveSupport::TestCase @version.authors = Array.new(6000) { "test author" } @version.validate - assert_equal @version.errors.messages[:authors], ["is too long (maximum is 64000 characters)"] + assert_equal(["is too long (maximum is 64000 characters)"], @version.errors.messages[:authors]) end end @@ -244,14 +300,14 @@ class VersionTest < ActiveSupport::TestCase @version.description = "test description" * 6000 @version.validate - assert_equal @version.errors.messages[:description], ["is too long (maximum is 64000 characters)"] + assert_equal(["is too long (maximum is 64000 characters)"], @version.errors.messages[:description]) end should "allow empty description" do @version.description = "" @version.validate - assert_equal @version.errors.messages[:description], [] + assert_empty(@version.errors.messages[:description]) end end @@ -264,14 +320,14 @@ class VersionTest < ActiveSupport::TestCase @version.summary = "test description" * 6000 @version.validate - assert_equal @version.errors.messages[:summary], ["is too long (maximum is 64000 characters)"] + assert_equal(["is too long (maximum is 64000 characters)"], @version.errors.messages[:summary]) end should "allow empty summary" do @version.summary = "" @version.validate - assert_equal @version.errors.messages[:summary], [] + assert_empty(@version.errors.messages[:summary]) end end @@ -286,6 +342,7 @@ class VersionTest < ActiveSupport::TestCase @version.required_ruby_version = @required_ruby_version @version.save! new_version = Version.find(@version.id) + assert_equal new_version.required_ruby_version, @required_ruby_version end end @@ -300,6 +357,7 @@ class VersionTest < ActiveSupport::TestCase @version.required_ruby_version = nil @version.save! nil_version = Version.find(@version.id) + assert_nil nil_version.required_ruby_version end end @@ -309,30 +367,35 @@ class VersionTest < ActiveSupport::TestCase should "be invalid with trailing zero in segments" do version = build(:version, rubygem: @rubygem, number: "1.0.0.0") - refute version.valid? - assert_equal version.errors.messages[:canonical_number], ["has already been taken. Existing version: 1.0.0"] + + refute_predicate version, :valid? + assert_equal(["has already been taken. Existing version: 1.0.0"], version.errors.messages[:canonical_number]) end should "be invalid with fewer zero in segments" do version = build(:version, rubygem: @rubygem, number: "1.0") - refute version.valid? - assert_equal version.errors.messages[:canonical_number], ["has already been taken. Existing version: 1.0.0"] + + refute_predicate version, :valid? + assert_equal(["has already been taken. Existing version: 1.0.0"], version.errors.messages[:canonical_number]) end should "be invalid with leading zero in significant segments" do version = build(:version, rubygem: @rubygem, number: "01.0.0") - refute version.valid? - assert_equal version.errors.messages[:canonical_number], ["has already been taken. Existing version: 1.0.0"] + + refute_predicate version, :valid? + assert_equal(["has already been taken. Existing version: 1.0.0"], version.errors.messages[:canonical_number]) end should "be valid in a different platform" do version = build(:version, rubygem: @rubygem, number: "1.0.0", platform: "win32") - assert version.valid? + + assert_predicate version, :valid? end should "be valid with prerelease" do version = build(:version, rubygem: @rubygem, number: "1.0.0.pre") - assert version.valid? + + assert_predicate version, :valid? end end @@ -343,32 +406,41 @@ class VersionTest < ActiveSupport::TestCase end subject { @version } + should allow_value("1.2.3.pre").for(:number) should_not allow_value("#YAML").for(:number) should_not allow_value("1.2.3-\"[javalol]\"").for(:number) should_not allow_value("0.8.45::Gem::PLATFORM::FAILBOAT").for(:number) should_not allow_value("1.2.3\n").for(:number) + should_not allow_value("1.2.3-bad").for(:number) + should_not allow_value("1.2.3.").for(:number) + should_not allow_value("1.2.3.gem").for(:number) should allow_value("ruby").for(:platform) should allow_value("mswin32").for(:platform) should allow_value("x86_64-linux").for(:platform) + should allow_value("it.is.fine").for(:platform) should_not allow_value("Gem::Platform::Ruby").for(:platform) + should_not allow_value("ruby.gem").for(:platform) should "be invalid with platform longer than maximum field length" do @version.platform = "r" * (Gemcutter::MAX_FIELD_LENGTH + 1) - refute @version.valid? - assert_equal @version.errors.messages[:platform], ["is too long (maximum is 255 characters)"] + + refute_predicate @version, :valid? + assert_equal(["is too long (maximum is 255 characters)"], @version.errors.messages[:platform]) end should "be invalid with number longer than maximum field length" do long_number_suffix = ".1" * (Gemcutter::MAX_FIELD_LENGTH + 1) @version.number = "1#{long_number_suffix}" - refute @version.valid? - assert_equal @version.errors.messages[:number], ["is too long (maximum is 255 characters)"] + + refute_predicate @version, :valid? + assert_equal(["is too long (maximum is 255 characters)"], @version.errors.messages[:number]) end should "be invalid with licenses longer than maximum field length" do @version.licenses = "r" * (Gemcutter::MAX_FIELD_LENGTH + 1) - refute @version.valid? - assert_equal @version.errors.messages[:licenses], ["is too long (maximum is 255 characters)"] + + refute_predicate @version, :valid? + assert_equal(["is too long (maximum is 255 characters)"], @version.errors.messages[:licenses]) end should "give number for #to_s" do @@ -376,18 +448,33 @@ class VersionTest < ActiveSupport::TestCase end should "not be platformed" do - refute @version.platformed? + refute_predicate @version, :platformed? end should "save full name" do @version.save! + assert_equal "#{@version.rubygem.name}-#{@version.number}", @version.full_name assert_equal @version.number, @version.slug end + should "compose gem_file_name" do + @version.full_name = "abc-1.1.1" + + assert_equal "abc-1.1.1.gem", @version.gem_file_name + end + should "raise an ActiveRecord::RecordNotFound if an invalid slug is given" do assert_raise ActiveRecord::RecordNotFound do - Version.find_from_slug!(@version.rubygem_id, "some stupid version 399") + @version.rubygem.find_version!(number: "some stupid version 399", platform: @version.platform) + end + end + + should "raise an ActiveRecord::RecordNotFound if the full name belongs to a different gem" do + prefix = create(:version, rubygem: build(:rubygem, name: "foo"), number: "0.1.0", platform: "ruby") + create(:version, rubygem: build(:rubygem, name: "foo-bar"), number: "0.1.0", platform: "ruby") + assert_raise ActiveRecord::RecordNotFound do + prefix.rubygem.find_version!(number: "bar-0.1.0", platform: prefix.platform) end end @@ -396,14 +483,14 @@ class VersionTest < ActiveSupport::TestCase version = create(:version, platform: platform) slug = "#{version.number}-#{platform}" - assert version.platformed? - assert_equal version.reload, Version.find_from_slug!(version.rubygem_id, slug) + assert_predicate version, :platformed? + assert_equal version.reload, version.rubygem.find_version!(number: version.number, platform: platform) assert_equal slug, version.slug end end should "have a default download count" do - assert @version.downloads_count.zero? + assert_predicate @version.downloads_count, :zero? end should "give no version flag for the latest version" do @@ -510,63 +597,71 @@ class VersionTest < ActiveSupport::TestCase should "give title and platform for #to_title" do @version.platform = "zomg" + assert_equal "#{@version.rubygem.name} (#{@version.number}-zomg)", @version.to_title end should "have description for info" do @version.description = @info + assert_equal @info, @version.info end should "have summary for info if description does not exist" do @version.description = nil @version.summary = @info + assert_equal @info, @version.info end should "have summary for info if description is blank" do @version.description = "" @version.summary = @info + assert_equal @info, @version.info end should "have some text for info if neither summary or description exist" do @version.description = nil @version.summary = nil + assert_equal "This rubygem does not have a description or summary.", @version.info end should "give 'N/A' for size when size not available" do @version.size = nil + assert_equal "N/A", @version.size end end context "with a very long authors string." do should "create without error" do - create(:version, - authors: [ - "Fbdoorman: David Pelaez", - "MiniFB:Appoxy", - "Dan Croak", - "Mike Burns", - "Jason Morrison", - "Joe Ferris", - "Eugene Bolshakov", - "Nick Quaranto", - "Josh Nichols", - "Mike Breen", - "Marcel G\303\266rner", - "Bence Nagy", - "Ben Mabey", - "Eloy Duran", - "Tim Pope", - "Mihai Anca", - "Mark Cornick", - "Shay Arnett", - "Jon Yurek", - "Chad Pytel" - ]) + assert_nothing_raised do + create(:version, + authors: [ + "Fbdoorman: David Pelaez", + "MiniFB:Appoxy", + "Dan Croak", + "Mike Burns", + "Jason Morrison", + "Joe Ferris", + "Eugene Bolshakov", + "Nick Quaranto", + "Josh Nichols", + "Mike Breen", + "Marcel G\303\266rner", + "Bence Nagy", + "Ben Mabey", + "Eloy Duran", + "Tim Pope", + "Mihai Anca", + "Mark Cornick", + "Shay Arnett", + "Jon Yurek", + "Chad Pytel" + ]) + end end end @@ -605,12 +700,13 @@ class VersionTest < ActiveSupport::TestCase end should "know if it is a prelease version" do - assert @prerelease.prerelease? - refute @release.prerelease? + assert_predicate @prerelease, :prerelease? + refute_predicate @release, :prerelease? end should "return prerelease gems from the prerelease named scope" do [@prerelease, @release].each(&:save!) + assert_equal [@prerelease], Version.prerelease assert_equal [@release], Version.release end @@ -626,7 +722,7 @@ class VersionTest < ActiveSupport::TestCase end should "show last pushed as latest version" do - assert_equal @three, @rubygem.versions.most_recent + assert_equal @three, @rubygem.most_recent_version end end @@ -645,7 +741,7 @@ class VersionTest < ActiveSupport::TestCase end should "know its latest version" do - assert_equal "0.7", @gem.versions.most_recent.number + assert_equal "0.7", @gem.most_recent_version.number end context "with multiple rubygems and versions created out of order" do @@ -679,9 +775,9 @@ class VersionTest < ActiveSupport::TestCase should "get the latest versions" do assert_equal [@dust, @haml, @rack, @thor, @json].map(&:authors), - Version.published(5).map(&:authors) + Version.published.limit(5).map(&:authors) assert_equal [@dust, @haml, @rack, @thor, @json, @rake].map(&:authors), - Version.published(6).map(&:authors) + Version.published.limit(6).map(&:authors) end end @@ -734,19 +830,20 @@ class VersionTest < ActiveSupport::TestCase # jumping around the place # b) people can't hijack the latest gem spot by building in the far # future, but pushing today - @subscribed_one.update(built_at: Time.zone.now - 3.days, - created_at: Time.zone.now - 1.day) - @subscribed_two.update(built_at: Time.zone.now - 2.days, - created_at: Time.zone.now - 2.days) + @subscribed_one.update(built_at: 3.days.ago, + created_at: 1.day.ago) + @subscribed_two.update(built_at: 2.days.ago, + created_at: 2.days.ago) # Even though gem two was build before gem one, it was pushed to gemcutter first # Thus, we should have from newest to oldest, gem one, then gem two expected = [@subscribed_one, @subscribed_two].map do |s| - s.created_at.to_s(:db) + s.created_at.to_fs(:db) end actual = Version.subscribed_to_by(@user).map do |s| - s.created_at.to_s(:db) + s.created_at.to_fs(:db) end + assert_equal expected, actual end end @@ -785,25 +882,29 @@ class VersionTest < ActiveSupport::TestCase should "be invalid with empty string as link" do @version.metadata = { "home" => "" } @version.validate - assert_equal @version.errors.messages[:metadata], ["['home'] does not appear to be a valid URL"] + + assert_equal(["['home'] does not appear to be a valid URL"], @version.errors.messages[:metadata]) end should "be invalid with invalid link" do @version.metadata = { "home" => "http:/github.com/bestgemever" } @version.validate - assert_equal @version.errors.messages[:metadata], ["['home'] does not appear to be a valid URL"] + + assert_equal(["['home'] does not appear to be a valid URL"], @version.errors.messages[:metadata]) end should "be valid with valid link" do @version.metadata = { "home" => "http://github.com/bestgemever" } + assert @version.validate - assert_equal @version.errors.messages[:metadata], [] + assert_empty(@version.errors.messages[:metadata]) end should "be invalid with value larger than 1024 bytes" do large_value = "v" * 1025 @version.metadata = { "key" => large_value } @version.validate + assert_equal @version.errors.messages[:metadata], ["metadata value ['#{large_value}'] is too large (maximum is 1024 bytes)"] end @@ -811,13 +912,15 @@ class VersionTest < ActiveSupport::TestCase large_key = "h" * 129 @version.metadata = { large_key => "value" } @version.validate + assert_equal @version.errors.messages[:metadata], ["metadata key ['#{large_key}'] is too large (maximum is 128 bytes)"] end should "be invalid with empty key" do @version.metadata = { "" => "value" } @version.validate - assert_equal @version.errors.messages[:metadata], ["metadata key is empty"] + + assert_equal(["metadata key is empty"], @version.errors.messages[:metadata]) end end end @@ -870,20 +973,22 @@ class VersionTest < ActiveSupport::TestCase should "validate authors the same twice" do g = Rubygem.new(name: "test-gem") - v = Version.new(authors: %w[arthurnn dwradcliffe], number: 1, platform: "ruby", rubygem: g) + v = Version.new(authors: %w[arthurnn dwradcliffe], number: 1, platform: "ruby", gem_platform: "ruby", rubygem: g) + assert_equal "arthurnn, dwradcliffe", v.authors - assert v.valid? + assert_predicate v, :valid? assert_equal "arthurnn, dwradcliffe", v.authors - assert v.valid? + assert_predicate v, :valid? end should "not allow full name collision" do g1 = Rubygem.create(name: "test-gem-733.t") - Version.create(authors: %w[arthurnn dwradcliffe], number: "0.0.1", platform: "ruby", rubygem: g1) + Version.create(authors: %w[arthurnn dwradcliffe], number: "0.0.1", platform: "ruby", gem_platform: "ruby", rubygem: g1) g2 = Rubygem.create(name: "test-gem") - v2 = Version.new(authors: %w[arthurnn dwradcliffe], number: "733.t-0.0.1", platform: "ruby", rubygem: g2) - refute v2.valid? - assert_equal [:full_name], v2.errors.attribute_names + v2 = Version.new(authors: %w[arthurnn dwradcliffe], number: "733.t-0.0.1", platform: "ruby", gem_platform: "ruby", rubygem: g2) + + refute_predicate v2, :valid? + assert_equal %i[full_name gem_full_name number], v2.errors.attribute_names end context "checksums" do @@ -898,10 +1003,47 @@ class VersionTest < ActiveSupport::TestCase should "should return nil on sha256_hex when sha not avaible" do @version.sha256 = nil + assert_nil @version.sha256_hex end end + context "with cert_chain" do + setup do + cert_chain = File.read(File.expand_path("../certs/chain.pem", __dir__)) + @version = build(:version, cert_chain: CertificateChainSerializer.load(cert_chain)) + end + + should "return latest not before time" do + latest_not_before = Time.new(2020, 4, 17).utc + @version.cert_chain.first.not_before = latest_not_before + @version.cert_chain.last.not_before = Time.new(2000, 4, 17).utc + + assert_equal latest_not_before, @version.cert_chain_valid_not_before + end + + should "return earliest not after time" do + earliest_not_after = Time.new(2022, 4, 17).utc + @version.cert_chain.first.not_after = Time.new(2100, 4, 17).utc + @version.cert_chain.last.not_after = earliest_not_after + + assert_equal earliest_not_after, @version.cert_chain_valid_not_after + end + + should "return true if signature is expired" do + @version.cert_chain.last.not_after = 1.year.ago + + assert_predicate @version, :signature_expired? + end + + should "return false if signature is not expired" do + @version.cert_chain.last.not_after = 1.year.from_now + @version.cert_chain.first.not_after = 1.year.from_now + + refute_predicate @version, :signature_expired? + end + end + context "created_between" do setup do @version = build(:version) @@ -909,21 +1051,26 @@ class VersionTest < ActiveSupport::TestCase @end_time = Time.zone.parse("2017-11-10") end - should "return versions created in the given range" do - @version.created_at = Time.zone.parse("2017-10-20") + should "return versions created in the given range ordered by date and id" do + created_at = Time.zone.parse("2017-10-20") + other_version = create(:version, created_at: created_at) + @version.created_at = created_at @version.save! - assert_contains Version.created_between(@start_time, @end_time), @version + + assert_equal [other_version.id, @version.id], Version.created_between(@start_time, @end_time).map(&:id) end should "NOT return versions created before the range begins" do @version.created_at = Time.zone.parse("2017-10-09") @version.save! + assert_does_not_contain Version.created_between(@start_time, @end_time), @version end should "NOT return versions after the range begins" do @version.created_at = Time.zone.parse("2017-11-11") @version.save! + assert_does_not_contain Version.created_between(@start_time, @end_time), @version end end diff --git a/test/models/web_hook_test.rb b/test/models/web_hook_test.rb new file mode 100644 index 00000000000..048eced3c6b --- /dev/null +++ b/test/models/web_hook_test.rb @@ -0,0 +1,354 @@ +require "test_helper" + +class WebHookTest < ActiveSupport::TestCase + include ActiveJob::TestHelper + + should belong_to :user + should belong_to(:rubygem).optional(true) + + def stub_hook_relay_request(url, webhook_id, authorization, max_attempts = 3) + stub_request(:post, "https://api.hookrelay.dev/hooks///webhook_id-#{webhook_id}") + .with( + headers: { + "Accept" => "*/*", + "Authorization" => authorization, + "Content-Type" => "application/json", + "Hr-Max-Attempts" => max_attempts, + "Hr-Target-Url" => url + }.compact + ) + end + + should "be valid for normal hook" do + hook = create(:web_hook) + + refute_predicate hook, :global? + assert_empty WebHook.global + assert_equal [hook], WebHook.specific + end + + should "be valid for global hook" do + hook = create(:global_web_hook) + + assert_nil hook.rubygem + assert_predicate hook, :global? + assert_equal [hook], WebHook.global + assert_empty WebHook.specific + end + + should "be invalid with url longer than maximum field length" do + long_domain = "r" * (Gemcutter::MAX_FIELD_LENGTH + 1) + hook = build(:web_hook, url: "https://#{long_domain}.com") + + refute_predicate hook, :valid? + assert_equal(["is too long (maximum is 255 characters)"], hook.errors.messages[:url]) + end + + should "require user" do + hook = build(:web_hook, user: nil) + + refute_predicate hook, :valid? + end + + ["badurl", "", nil].each do |url| + should "invalidate with #{url.inspect} as the url" do + hook = build(:web_hook, url: url) + + refute_predicate hook, :valid? + end + end + + context "with a global webhook for a gem" do + setup do + @url = "http://example.org" + @user = create(:user) + @webhook = create(:global_web_hook, user: @user, url: @url) + end + + should "not be able to create a webhook under this user, gem, and url" do + webhook = WebHook.new(user: @user, + url: @url) + + refute_predicate webhook, :valid? + end + + should "be able to create a webhook for a url under this user and gem" do + webhook = WebHook.new(user: @user, + url: "http://example.net") + + assert_predicate webhook, :valid? + end + + should "be able to create a webhook for another user under this url" do + other_user = create(:user) + webhook = WebHook.new(user: other_user, + url: @url) + + assert_predicate webhook, :valid? + end + end + + context "with a webhook for a gem" do + setup do + @url = "http://example.org" + @user = create(:user) + @rubygem = create(:rubygem) + @webhook = create(:web_hook, user: @user, rubygem: @rubygem, url: @url) + end + + should "show limited attributes for to_json" do + assert_equal( + { + "url" => @url, + "failure_count" => @webhook.failure_count + }, JSON.load(@webhook.to_json) + ) + end + + should "show limited attributes for to_xml" do + xml = Nokogiri.parse(@webhook.to_xml) + + assert_equal "web-hook", xml.root.name + assert_equal %w[failure-count url], xml.root.children.select(&:element?).map(&:name).sort + assert_equal @webhook.url, xml.at_css("url").content + assert_equal @webhook.failure_count, xml.at_css("failure-count").content.to_i + end + + should "show limited attributes for to_yaml" do + assert_equal( + { + "url" => @url, + "failure_count" => @webhook.failure_count + }, YAML.safe_load(@webhook.to_yaml) + ) + end + + should "not be able to create a webhook under this user, gem, and url" do + webhook = WebHook.new(user: @user, + rubygem: @rubygem, + url: @url) + + refute_predicate webhook, :valid? + end + + should "be able to create a webhook for a url under this user and gem" do + webhook = WebHook.new(user: @user, + rubygem: @rubygem, + url: "http://example.net") + + assert_predicate webhook, :valid? + end + + should "be able to create a webhook for another rubygem under this user and url" do + other_rubygem = create(:rubygem) + webhook = WebHook.new(user: @user, + rubygem: other_rubygem, + url: @url) + + assert_predicate webhook, :valid? + end + + should "be able to create a webhook for another user under this rubygem and url" do + other_user = create(:user) + webhook = WebHook.new(user: other_user, + rubygem: @rubygem, + url: @url) + + assert_predicate webhook, :valid? + end + + should "be able to create a global webhook under this user and url" do + webhook = WebHook.new(user: @user, + url: @url) + + assert_predicate webhook, :valid? + end + end + + context "with a non-global hook job" do + setup do + @url = "http://example.com/gemcutter" + @rubygem = create(:rubygem) + @version = create(:version, rubygem: @rubygem) + @hook = create(:web_hook, rubygem: @rubygem, url: @url) + end + + should "include an Authorization header" do + authorization = Digest::SHA2.hexdigest(@rubygem.name + @version.number + @hook.user.api_key) + stub_hook_relay_request(@url, @hook.id, authorization).to_return_json(status: 200, body: { id: 1 }) + + perform_enqueued_jobs only: NotifyWebHookJob do + @hook.fire("https", "rubygems.org", @version) + end + end + + should "include an Authorization header for a user with no API key" do + @hook.user.update(api_key: nil) + authorization = Digest::SHA2.hexdigest(@rubygem.name + @version.number) + stub_hook_relay_request(@url, @hook.id, authorization).to_return_json(status: 200, body: { id: 1 }) + + perform_enqueued_jobs only: NotifyWebHookJob do + @hook.fire("https", "rubygems.org", @version) + end + end + + should "include an Authorization header for a user with many API keys" do + @hook.user.update(api_key: nil) + create(:api_key, owner: @hook.user) + authorization = Digest::SHA2.hexdigest(@rubygem.name + @version.number + @hook.user.api_keys.first.hashed_key) + stub_hook_relay_request(@url, @hook.id, authorization).to_return(status: 200, body: { id: 1 }.to_json, + headers: { "Content-Type" => "application/json" }) + + perform_enqueued_jobs only: NotifyWebHookJob do + @hook.fire("https", "rubygems.org", @version) + end + end + + should "not increment failure count for hook" do + stub_hook_relay_request(@url, @hook.id, nil).to_return_json(status: 200, body: { id: 1 }) + + perform_enqueued_jobs only: NotifyWebHookJob do + @hook.fire("https", "rubygems.org", @version) + end + + assert_predicate @hook.failure_count, :zero? + end + end + + context "yaml" do + setup do + @webhook = create(:web_hook) + end + + should "return its payload" do + assert_equal @webhook.payload, YAML.safe_load(@webhook.to_yaml) + end + + should "nest properly" do + assert_equal [@webhook.payload], YAML.safe_load([@webhook].to_yaml) + end + end + + context "#success!" do + setup do + @web_hook = create(:web_hook) + end + + should "increment the successes_since_last_failure" do + assert_difference -> { @web_hook.reload.successes_since_last_failure } do + @web_hook.success!(completed_at: DateTime.now) + end + end + + should "reset failures_since_last_success" do + @web_hook.increment! :failures_since_last_success + @web_hook.success!(completed_at: DateTime.now) + + assert_equal 0, @web_hook.failures_since_last_success + end + + should "set last_success" do + completed_at = 1.minute.ago + @web_hook.success!(completed_at:) + + assert_equal completed_at, @web_hook.last_success + end + + should "not change last_failure" do + @web_hook.update!(last_failure: 2.minutes.ago) + assert_no_changes -> { @web_hook.last_failure } do + completed_at = 1.minute.ago + @web_hook.success!(completed_at:) + end + end + + should "not change failure_count" do + @web_hook.increment! :failure_count + assert_no_changes -> { @web_hook.failure_count } do + completed_at = 1.minute.ago + @web_hook.success!(completed_at:) + end + end + end + + context "#failure!" do + setup do + @web_hook = create(:web_hook) + end + + should "increment the failure_count" do + assert_difference -> { @web_hook.reload.failures_since_last_success } do + @web_hook.failure!(completed_at: DateTime.now) + end + end + + should "increment the failures_since_last_success" do + assert_difference -> { @web_hook.reload.failures_since_last_success } do + @web_hook.failure!(completed_at: DateTime.now) + end + end + + should "reset successes_since_last_failure" do + @web_hook.increment! :successes_since_last_failure + @web_hook.failure!(completed_at: DateTime.now) + + assert_equal 0, @web_hook.successes_since_last_failure + end + + should "set last_failure" do + completed_at = 1.minute.ago + @web_hook.failure!(completed_at:) + + assert_equal completed_at, @web_hook.last_failure + end + + should "not change last_success" do + @web_hook.update!(last_success: 2.minutes.ago) + assert_no_changes -> { @web_hook.last_success } do + completed_at = 1.minute.ago + @web_hook.failure!(completed_at:) + end + end + + should "disable when too many failures since last success" do + @web_hook.update!( + failures_since_last_success: WebHook::FAILURE_DISABLE_THRESHOLD - 1, + last_success: (WebHook::FAILURE_DISABLE_DURATION + 1.minute).ago + ) + @web_hook.failure!(completed_at: DateTime.now) + + refute_predicate @web_hook, :enabled? + end + + should "disable when too many failures since creation with no success" do + @web_hook.update!( + failures_since_last_success: WebHook::FAILURE_DISABLE_THRESHOLD - 1, + last_success: nil, + created_at: (WebHook::FAILURE_DISABLE_DURATION + 1.minute).ago + ) + @web_hook.failure!(completed_at: DateTime.now) + + refute_predicate @web_hook, :enabled? + end + + should "not disable when too many failures but recent success" do + @web_hook.update!( + failures_since_last_success: WebHook::FAILURE_DISABLE_THRESHOLD + 100, + last_success: 1.minute.ago + ) + @web_hook.failure!(completed_at: DateTime.now) + + assert_predicate @web_hook, :enabled? + end + + should "not disable when too many failures but recent creation" do + @web_hook.update!( + failures_since_last_success: WebHook::FAILURE_DISABLE_THRESHOLD + 100, + created_at: 1.minute.ago + ) + @web_hook.failure!(completed_at: DateTime.now) + + assert_predicate @web_hook, :enabled? + end + end +end diff --git a/test/models/webauthn_credential_test.rb b/test/models/webauthn_credential_test.rb new file mode 100644 index 00000000000..78458a31c32 --- /dev/null +++ b/test/models/webauthn_credential_test.rb @@ -0,0 +1,125 @@ +require "test_helper" + +class WebauthnCredentialTest < ActiveSupport::TestCase + subject { build(:webauthn_credential) } + + should belong_to :user + should validate_presence_of(:external_id) + should validate_uniqueness_of(:external_id) + should validate_length_of(:external_id).is_at_most(512) + should validate_presence_of(:public_key) + should validate_length_of(:public_key).is_at_most(512) + should validate_presence_of(:nickname) + should validate_uniqueness_of(:nickname).scoped_to(:user_id) + should validate_length_of(:nickname).is_at_most(64) + should validate_presence_of(:sign_count) + should validate_numericality_of(:sign_count).is_greater_than_or_equal_to(0) + + setup do + @user = create(:user) + end + + context "after create" do + context "when user creates a webauthn credential and totp is disabled" do + setup do + @webauthn_credential = create(:webauthn_credential, user: @user) + end + + should "set user mfa level to ui_and_api" do + assert_equal "ui_and_api", @user.reload.mfa_level + end + + should "set user mfa recovery codes" do + assert_equal 10, @user.reload.mfa_hashed_recovery_codes.count + end + end + + context "when user has totp is enabled and creates a webauthn credential" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_gem_signin) + @codes = @user.new_mfa_recovery_codes + @webauthn_credential = create(:webauthn_credential, user: @user) + end + + should "not change user mfa level" do + assert_equal "ui_and_gem_signin", @user.reload.mfa_level + end + + should "not change user mfa recovery codes" do + assert_equal @codes.length, @user.reload.mfa_hashed_recovery_codes.length + @codes.zip(@user.reload.mfa_hashed_recovery_codes).each do |code, hashed_code| + assert_equal BCrypt::Password.new(hashed_code), code + end + end + end + + context "when user has two webauthn credentials and totp is disabled" do + setup do + @webauthn_credential = create(:webauthn_credential, user: @user) + @user.update!(mfa_level: "ui_and_gem_signin") + @codes = @user.new_mfa_recovery_codes + @webauthn_credential2 = create(:webauthn_credential, user: @user) + end + + should "not change user mfa level" do + assert_equal "ui_and_gem_signin", @user.reload.mfa_level + end + + should "not change user mfa recovery codes" do + @codes.zip(@user.reload.mfa_hashed_recovery_codes).each do |code, hashed_code| + assert_equal BCrypt::Password.new(hashed_code), code + end + end + end + end + + context "after destroy" do + setup do + @webauthn_credential = create(:webauthn_credential, user: @user) + end + + context "when user destroys a webauthn credential and totp is disabled" do + should "disable mfa" do + assert_changes -> { @user.reload.mfa_level }, from: "ui_and_api", to: "disabled" do + @webauthn_credential.destroy! + end + end + + should "clear mfa recovery codes" do + assert_changes -> { @user.reload.mfa_hashed_recovery_codes.count }, from: 10, to: 0 do + @webauthn_credential.destroy! + end + end + end + + context "when user has totp is enabled and destroys a webauthn credential" do + setup do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + @webauthn_credential.destroy! + end + + should "not change user mfa level" do + assert_equal "ui_and_api", @user.reload.mfa_level + end + + should "not change user mfa recovery codes" do + assert_equal 10, @user.reload.mfa_hashed_recovery_codes.count + end + end + + context "when user has two webauthn credentials and totp is disabled" do + setup do + @webauthn_credential2 = create(:webauthn_credential, user: @user) + @webauthn_credential.destroy! + end + + should "not change user mfa level" do + assert_equal "ui_and_api", @user.reload.mfa_level + end + + should "not change user mfa recovery codes" do + assert_equal 10, @user.reload.mfa_hashed_recovery_codes.count + end + end + end +end diff --git a/test/policies/admin/api_key_policy_test.rb b/test/policies/admin/api_key_policy_test.rb new file mode 100644 index 00000000000..97da3b5fb3d --- /dev/null +++ b/test/policies/admin/api_key_policy_test.rb @@ -0,0 +1,41 @@ +require "test_helper" + +class Admin::ApiKeyPolicyTest < AdminPolicyTestCase + setup do + @api_key = FactoryBot.create(:api_key) + @admin = FactoryBot.create(:admin_github_user, :is_admin) + @non_admin = FactoryBot.create(:admin_github_user) + end + + def test_scope + assert_equal [@api_key], policy_scope!( + @admin, + ApiKey + ).to_a + end + + def test_avo_index + refute_authorizes @admin, ApiKey, :avo_index? + refute_authorizes @non_admin, ApiKey, :avo_index? + end + + def test_avo_show + assert_authorizes @admin, @api_key, :avo_show? + refute_authorizes @non_admin, @api_key, :avo_show? + end + + def test_avo_create + refute_authorizes @admin, ApiKey, :avo_create? + refute_authorizes @non_admin, ApiKey, :avo_create? + end + + def test_avo_update + refute_authorizes @admin, @api_key, :avo_update? + refute_authorizes @non_admin, @api_key, :avo_update? + end + + def test_avo_destroy + refute_authorizes @admin, @api_key, :avo_destroy? + refute_authorizes @non_admin, @api_key, :avo_destroy? + end +end diff --git a/test/policies/admin/api_key_rubygem_scope_policy_test.rb b/test/policies/admin/api_key_rubygem_scope_policy_test.rb new file mode 100644 index 00000000000..c4b65646a28 --- /dev/null +++ b/test/policies/admin/api_key_rubygem_scope_policy_test.rb @@ -0,0 +1,42 @@ +require "test_helper" + +class Admin::ApiKeyRubygemScopePolicyTest < AdminPolicyTestCase + setup do + @scope = FactoryBot.create(:api_key_rubygem_scope) + @admin = FactoryBot.create(:admin_github_user, :is_admin) + @non_admin = FactoryBot.create(:admin_github_user) + end + + def test_scope + assert_equal [@scope], policy_scope!( + @admin, + ApiKeyRubygemScope + ).to_a + end + + def test_avo_index + refute_authorizes @admin, ApiKeyRubygemScope, :avo_index? + refute_authorizes @non_admin, ApiKeyRubygemScope, :avo_index? + end + + def test_avo_show + assert_authorizes @admin, @scope, :avo_show? + + refute_authorizes @non_admin, @scope, :avo_show? + end + + def test_avo_create + refute_authorizes @admin, ApiKeyRubygemScope, :avo_create? + refute_authorizes @non_admin, ApiKeyRubygemScope, :avo_create? + end + + def test_avo_update + refute_authorizes @admin, @scope, :avo_update? + refute_authorizes @non_admin, @scope, :avo_update? + end + + def test_avo_destroy + refute_authorizes @admin, @scope, :avo_destroy? + refute_authorizes @non_admin, @scope, :avo_destroy? + end +end diff --git a/test/policies/admin/avo_policies_test.rb b/test/policies/admin/avo_policies_test.rb new file mode 100644 index 00000000000..e03ee0e9cc0 --- /dev/null +++ b/test/policies/admin/avo_policies_test.rb @@ -0,0 +1,35 @@ +require "test_helper" + +class Admin::AvoPoliciesTest < AdminPolicyTestCase + def test_association_methods_defined + resources = Avo::App.init_resources + association_actions = %w[create attach detach destroy edit show view] + + aggregate_assertions do + resources.each do |resource| + policy = + if resource.authorization_policy + resource.authorization_policy.new(nil, resource) + else + policy!(nil, resource) + end + + refute_nil policy + + aggregate_assertions policy.class.name do + resource.fields.each do |field| + aggregate_assertions field.id do + case field + when Avo::Fields::HasBaseField + + association_actions.each do |action| + assert_respond_to policy, :"#{action}_#{field.id}?" + end + end + end + end + end + end + end + end +end diff --git a/test/policies/admin/deletion_policy_test.rb b/test/policies/admin/deletion_policy_test.rb new file mode 100644 index 00000000000..50508bc5863 --- /dev/null +++ b/test/policies/admin/deletion_policy_test.rb @@ -0,0 +1,44 @@ +require "test_helper" + +class Admin::DeletionPolicyTest < AdminPolicyTestCase + setup do + @version = create(:version) + @deletion = Deletion.create!(version: @version, user: create(:user)) + @admin = create(:admin_github_user, :is_admin) + @non_admin = create(:admin_github_user) + end + + def test_scope + assert_equal [@deletion], policy_scope!( + @admin, + Deletion + ).to_a + end + + def test_avo_index + assert_authorizes @admin, Deletion, :avo_index? + + refute_authorizes @non_admin, Deletion, :avo_index? + end + + def test_avo_show + assert_authorizes @admin, @deletion, :avo_show? + + refute_authorizes @non_admin, @deletion, :avo_show? + end + + def test_avo_create + refute_authorizes @admin, Deletion, :avo_create? + refute_authorizes @non_admin, Deletion, :avo_create? + end + + def test_avo_update + refute_authorizes @admin, @deletion, :avo_update? + refute_authorizes @non_admin, @deletion, :avo_update? + end + + def test_avo_destroy + refute_authorizes @admin, @deletion, :avo_destroy? + refute_authorizes @non_admin, @deletion, :avo_destroy? + end +end diff --git a/test/policies/admin/events/organization_event_policy_test.rb b/test/policies/admin/events/organization_event_policy_test.rb new file mode 100644 index 00000000000..103096ebebd --- /dev/null +++ b/test/policies/admin/events/organization_event_policy_test.rb @@ -0,0 +1,34 @@ +require "test_helper" + +class Admin::Events::OrganizationEventPolicyTest < AdminPolicyTestCase + setup do + @organization = FactoryBot.create(:organization) + @event = Events::OrganizationEvent.first + @admin = FactoryBot.create(:admin_github_user, :is_admin) + @non_admin = FactoryBot.create(:admin_github_user) + end + + def test_scope + assert_equal [@event], policy_scope!(@admin, Events::OrganizationEvent).to_a + end + + def test_show + assert_authorizes @admin, @event, :avo_show? + refute_authorizes @non_admin, @event, :avo_show? + end + + def test_create + refute_authorizes @admin, @event, :avo_create? + refute_authorizes @non_admin, @event, :avo_create? + end + + def test_update + refute_authorizes @admin, @event, :avo_update? + refute_authorizes @non_admin, @event, :avo_update? + end + + def test_destroy + refute_authorizes @admin, @event, :avo_destroy? + refute_authorizes @non_admin, @event, :avo_destroy? + end +end diff --git a/test/policies/admin/gem_name_reservation_policy_test.rb b/test/policies/admin/gem_name_reservation_policy_test.rb new file mode 100644 index 00000000000..ec26de4bb90 --- /dev/null +++ b/test/policies/admin/gem_name_reservation_policy_test.rb @@ -0,0 +1,39 @@ +require "test_helper" + +class Admin::GemNameReservationPolicyTest < AdminPolicyTestCase + setup do + @scope = create(:gem_name_reservation) + @admin = create(:admin_github_user, :is_admin) + end + + def test_scope + assert_equal [@scope], policy_scope!( + @admin, + GemNameReservation + ).to_a + end + + def test_avo_index + assert_authorizes @admin, GemNameReservation, :avo_index? + end + + def test_avo_show + assert_authorizes @admin, GemNameReservation, :avo_show? + end + + def test_avo_create + assert_authorizes @admin, GemNameReservation, :avo_create? + end + + def test_avo_destroy + assert_authorizes @admin, GemNameReservation, :avo_destroy? + end + + def test_avo_search + assert_authorizes @admin, GemNameReservation, :avo_search? + end + + def test_avo_update + refute_authorizes @admin, GemNameReservation, :avo_update? + end +end diff --git a/test/policies/admin/gem_typo_exception_policy_test.rb b/test/policies/admin/gem_typo_exception_policy_test.rb new file mode 100644 index 00000000000..85b94f025e1 --- /dev/null +++ b/test/policies/admin/gem_typo_exception_policy_test.rb @@ -0,0 +1,47 @@ +require "test_helper" + +class Admin::GemTypoExceptionPolicyTest < AdminPolicyTestCase + setup do + @exception = create(:gem_typo_exception) + + @admin = create(:admin_github_user, :is_admin) + @non_admin = create(:admin_github_user) + end + + def test_scope + assert_equal [@exception], policy_scope!( + @admin, + GemTypoException + ).to_a + end + + def test_avo_index + assert_authorizes @admin, GemTypoException, :avo_index? + + refute_authorizes @non_admin, GemTypoException, :avo_index? + end + + def test_avo_show + assert_authorizes @admin, @exception, :avo_show? + + refute_authorizes @non_admin, @exception, :avo_show? + end + + def test_avo_create + assert_authorizes @admin, GemTypoException, :avo_create? + + refute_authorizes @non_admin, GemTypoException, :avo_create? + end + + def test_avo_update + assert_authorizes @admin, @exception, :avo_update? + + refute_authorizes @non_admin, @exception, :avo_update? + end + + def test_avo_destroy + assert_authorizes @admin, @exception, :avo_destroy? + + refute_authorizes @non_admin, @exception, :avo_destroy? + end +end diff --git a/test/policies/admin/geoip_info_policy_test.rb b/test/policies/admin/geoip_info_policy_test.rb new file mode 100644 index 00000000000..9c81adb8439 --- /dev/null +++ b/test/policies/admin/geoip_info_policy_test.rb @@ -0,0 +1,44 @@ +require "test_helper" + +class Admin::GeoipInfoPolicyTest < AdminPolicyTestCase + setup do + @geoip_info = create(:geoip_info) + + @admin = create(:admin_github_user, :is_admin) + @non_admin = create(:admin_github_user) + end + + def test_scope + assert_equal [@geoip_info], policy_scope!( + @admin, + GeoipInfo + ).to_a + end + + def test_avo_index + assert_authorizes @admin, GeoipInfo, :avo_index? + + refute_authorizes @non_admin, GeoipInfo, :avo_index? + end + + def test_avo_show + assert_authorizes @admin, @geoip_info, :avo_show? + + refute_authorizes @non_admin, @geoip_info, :avo_show? + end + + def test_avo_create + refute_authorizes @admin, GeoipInfo, :avo_create? + refute_authorizes @non_admin, GeoipInfo, :avo_create? + end + + def test_avo_update + refute_authorizes @admin, @geoip_info, :avo_update? + refute_authorizes @non_admin, @geoip_info, :avo_update? + end + + def test_avo_destroy + refute_authorizes @admin, @geoip_info, :avo_destroy? + refute_authorizes @non_admin, @geoip_info, :avo_destroy? + end +end diff --git a/test/policies/admin/github_user_policy_test.rb b/test/policies/admin/github_user_policy_test.rb new file mode 100644 index 00000000000..88eed2da631 --- /dev/null +++ b/test/policies/admin/github_user_policy_test.rb @@ -0,0 +1,48 @@ +require "test_helper" + +class Admin::GitHubUserPolicyTest < AdminPolicyTestCase + def policy_class + Admin::GitHubUserPolicy + end + + setup do + @user = FactoryBot.create(:admin_github_user) + @admin = FactoryBot.create(:admin_github_user, :is_admin) + end + + def test_scope + assert_equal [@user], policy_scope!( + @user, + Admin::GitHubUser + ).to_a + + assert_equal [@user, @admin], policy_scope!( + @admin, + Admin::GitHubUser + ).to_a + end + + def test_avo_show + assert_authorizes @admin, @user, :avo_show? + assert_authorizes @admin, @user, :avo_show? + assert_authorizes @admin, @admin, :avo_show? + + refute_authorizes @user, @user, :avo_show? + refute_authorizes @user, @admin, :avo_show? + end + + def test_avo_create + refute_authorizes @user, @user, :avo_create? + refute_authorizes @admin, @admin, :avo_create? + end + + def test_avo_update + refute_authorizes @user, @user, :avo_update? + refute_authorizes @admin, @admin, :avo_update? + end + + def test_avo_destroy + refute_authorizes @user, @user, :avo_destroy? + refute_authorizes @admin, @admin, :avo_destroy? + end +end diff --git a/test/policies/admin/link_verification_policy_test.rb b/test/policies/admin/link_verification_policy_test.rb new file mode 100644 index 00000000000..a957581422a --- /dev/null +++ b/test/policies/admin/link_verification_policy_test.rb @@ -0,0 +1,46 @@ +require "test_helper" + +class Admin::LinkVerificationPolicyTest < AdminPolicyTestCase + setup do + @verification = create(:link_verification) + + @admin = create(:admin_github_user, :is_admin) + @non_admin = create(:admin_github_user) + end + + def test_scope + home_verification = @verification.linkable.link_verifications.for_uri(@verification.linkable.linkset.home).sole + + assert_equal [home_verification, @verification], policy_scope!( + @admin, + LinkVerification + ).to_a + end + + def test_avo_index + assert_authorizes @admin, LinkVerification, :avo_index? + + refute_authorizes @non_admin, LinkVerification, :avo_index? + end + + def test_avo_show + assert_authorizes @admin, @verification, :avo_show? + + refute_authorizes @non_admin, @verification, :avo_show? + end + + def test_avo_create + refute_authorizes @admin, LinkVerification, :avo_create? + refute_authorizes @non_admin, LinkVerification, :avo_create? + end + + def test_avo_update + refute_authorizes @admin, @verification, :avo_update? + refute_authorizes @non_admin, @verification, :avo_update? + end + + def test_avo_destroy + refute_authorizes @admin, @verification, :avo_destroy? + refute_authorizes @non_admin, @verification, :avo_destroy? + end +end diff --git a/test/policies/admin/linkset_policy_test.rb b/test/policies/admin/linkset_policy_test.rb new file mode 100644 index 00000000000..cc849f0fe65 --- /dev/null +++ b/test/policies/admin/linkset_policy_test.rb @@ -0,0 +1,43 @@ +require "test_helper" + +class Admin::LinksetPolicyTest < AdminPolicyTestCase + setup do + @linkset = FactoryBot.create(:rubygem).linkset + @admin = FactoryBot.create(:admin_github_user, :is_admin) + @non_admin = FactoryBot.create(:admin_github_user) + end + + def test_scope + assert_equal [@linkset], policy_scope!( + @admin, + Linkset + ).to_a + end + + def test_avo_index + assert_authorizes @admin, Linkset, :avo_index? + + refute_authorizes @non_admin, Linkset, :avo_index? + end + + def test_avo_show + assert_authorizes @admin, @linkset, :avo_show? + + refute_authorizes @non_admin, @linkset, :avo_show? + end + + def test_avo_create + refute_authorizes @admin, Linkset, :avo_create? + refute_authorizes @non_admin, Linkset, :avo_create? + end + + def test_avo_update + refute_authorizes @admin, @linkset, :avo_update? + refute_authorizes @non_admin, @linkset, :avo_update? + end + + def test_avo_destroy + refute_authorizes @admin, @linkset, :avo_destroy? + refute_authorizes @non_admin, @linkset, :avo_destroy? + end +end diff --git a/test/policies/admin/log_ticket_policy_test.rb b/test/policies/admin/log_ticket_policy_test.rb new file mode 100644 index 00000000000..b62d007ba1c --- /dev/null +++ b/test/policies/admin/log_ticket_policy_test.rb @@ -0,0 +1,42 @@ +require "test_helper" + +class Admin::LogTicketPolicyTest < AdminPolicyTestCase + setup do + @log_ticket = FactoryBot.create(:log_ticket) + @admin = FactoryBot.create(:admin_github_user, :is_admin) + @non_admin = FactoryBot.create(:admin_github_user) + end + + def test_scope + assert_equal [@log_ticket], policy_scope!( + @admin, + LogTicket + ).to_a + end + + def test_avo_index + refute_authorizes @admin, ApiKey, :avo_index? + refute_authorizes @non_admin, ApiKey, :avo_index? + end + + def test_avo_show + assert_authorizes @admin, @log_ticket, :avo_show? + + refute_authorizes @non_admin, @log_ticket, :avo_show? + end + + def test_avo_create + refute_authorizes @admin, ApiKey, :avo_create? + refute_authorizes @non_admin, ApiKey, :avo_create? + end + + def test_avo_update + refute_authorizes @admin, @log_ticket, :avo_update? + refute_authorizes @non_admin, @log_ticket, :avo_update? + end + + def test_avo_destroy + refute_authorizes @admin, @log_ticket, :avo_destroy? + refute_authorizes @non_admin, @log_ticket, :avo_destroy? + end +end diff --git a/test/policies/admin/maintenance_tasks/run_policy_test.rb b/test/policies/admin/maintenance_tasks/run_policy_test.rb new file mode 100644 index 00000000000..042dcd00c2b --- /dev/null +++ b/test/policies/admin/maintenance_tasks/run_policy_test.rb @@ -0,0 +1,43 @@ +require "test_helper" + +class Admin::MaintenanceTasks::RunPolicyTest < AdminPolicyTestCase + setup do + @run = create(:maintenance_tasks_run) + @admin = FactoryBot.create(:admin_github_user, :is_admin) + @non_admin = FactoryBot.create(:admin_github_user) + end + + def test_scope + assert_equal [@run], policy_scope!( + @admin, + MaintenanceTasks::Run + ).to_a + end + + def test_avo_index + assert_authorizes @admin, MaintenanceTasks::Run, :avo_index? + + refute_authorizes @non_admin, MaintenanceTasks::Run, :avo_index? + end + + def test_avo_show + assert_authorizes @admin, @run, :avo_show? + + refute_authorizes @non_admin, @run, :avo_show? + end + + def test_avo_create + refute_authorizes @admin, MaintenanceTasks::Run, :avo_create? + refute_authorizes @non_admin, MaintenanceTasks::Run, :avo_create? + end + + def test_avo_update + refute_authorizes @admin, @run, :avo_update? + refute_authorizes @non_admin, @run, :avo_update? + end + + def test_avo_destroy + refute_authorizes @admin, @run, :avo_destroy? + refute_authorizes @non_admin, @run, :avo_destroy? + end +end diff --git a/test/policies/admin/membership_policy_test.rb b/test/policies/admin/membership_policy_test.rb new file mode 100644 index 00000000000..4474bcc0a65 --- /dev/null +++ b/test/policies/admin/membership_policy_test.rb @@ -0,0 +1,39 @@ +require "test_helper" + +class Admin::MembershipPolicyTest < AdminPolicyTestCase + setup do + @membership = FactoryBot.create(:membership) + @admin = FactoryBot.create(:admin_github_user, :is_admin) + @non_admin = FactoryBot.create(:admin_github_user) + end + + def test_scope + assert_equal [@membership], policy_scope!(@admin, Membership).to_a + end + + def test_avo_index + refute_authorizes @admin, Membership, :avo_index? + refute_authorizes @non_admin, Membership, :avo_index? + end + + def test_avo_show + assert_authorizes @admin, @membership, :avo_show? + + refute_authorizes @non_admin, @membership, :avo_show? + end + + def test_avo_create + refute_authorizes @admin, Membership, :avo_create? + refute_authorizes @non_admin, Membership, :avo_create? + end + + def test_avo_update + refute_authorizes @admin, @membership, :avo_update? + refute_authorizes @non_admin, @membership, :avo_update? + end + + def test_avo_destroy + refute_authorizes @admin, @membership, :avo_destroy? + refute_authorizes @non_admin, @membership, :avo_destroy? + end +end diff --git a/test/policies/admin/oidc/api_key_role_policy_test.rb b/test/policies/admin/oidc/api_key_role_policy_test.rb new file mode 100644 index 00000000000..41861431aab --- /dev/null +++ b/test/policies/admin/oidc/api_key_role_policy_test.rb @@ -0,0 +1,46 @@ +require "test_helper" + +class Admin::OIDC::ApiKeyRolePolicyTest < AdminPolicyTestCase + setup do + @api_key_role = FactoryBot.create(:oidc_api_key_role) + + @admin = FactoryBot.create(:admin_github_user, :is_admin) + @non_admin = FactoryBot.create(:admin_github_user) + end + + def test_scope + assert_equal [@api_key_role], policy_scope!( + @admin, + OIDC::ApiKeyRole + ).to_a + end + + def test_avo_index + assert_authorizes @admin, OIDC::ApiKeyRole, :avo_index? + + refute_authorizes @non_admin, OIDC::ApiKeyRole, :avo_index? + end + + def test_avo_show + assert_authorizes @admin, @api_key_role, :avo_show? + + refute_authorizes @non_admin, @api_key_role, :avo_show? + end + + def test_avo_create + assert_authorizes @admin, OIDC::ApiKeyRole, :avo_create? + + refute_authorizes @non_admin, OIDC::ApiKeyRole, :avo_create? + end + + def test_avo_update + assert_authorizes @admin, @api_key_role, :avo_update? + + refute_authorizes @non_admin, @api_key_role, :avo_update? + end + + def test_avo_destroy + refute_authorizes @admin, @api_key_role, :avo_destroy? + refute_authorizes @non_admin, @api_key_role, :avo_destroy? + end +end diff --git a/test/policies/admin/oidc/id_token_policy_test.rb b/test/policies/admin/oidc/id_token_policy_test.rb new file mode 100644 index 00000000000..d088feb5f0d --- /dev/null +++ b/test/policies/admin/oidc/id_token_policy_test.rb @@ -0,0 +1,44 @@ +require "test_helper" + +class Admin::OIDC::IdTokenPolicyTest < AdminPolicyTestCase + setup do + @id_token = FactoryBot.create(:oidc_id_token) + + @admin = FactoryBot.create(:admin_github_user, :is_admin) + @non_admin = FactoryBot.create(:admin_github_user) + end + + def test_scope + assert_equal [@id_token], policy_scope!( + @admin, + OIDC::IdToken + ).to_a + end + + def test_avo_index + assert_authorizes @admin, OIDC::IdToken, :avo_index? + + refute_authorizes @non_admin, OIDC::IdToken, :avo_index? + end + + def test_avo_show + assert_authorizes @admin, @id_token, :avo_show? + + refute_authorizes @non_admin, @id_token, :avo_show? + end + + def test_avo_create + refute_authorizes @admin, OIDC::IdToken, :avo_create? + refute_authorizes @non_admin, OIDC::IdToken, :avo_create? + end + + def test_avo_update + refute_authorizes @admin, @id_token, :avo_update? + refute_authorizes @non_admin, @id_token, :avo_update? + end + + def test_avo_destroy + refute_authorizes @admin, @id_token, :avo_destroy? + refute_authorizes @non_admin, @id_token, :avo_destroy? + end +end diff --git a/test/policies/admin/oidc/pending_trusted_publisher_policy_test.rb b/test/policies/admin/oidc/pending_trusted_publisher_policy_test.rb new file mode 100644 index 00000000000..f49c937f744 --- /dev/null +++ b/test/policies/admin/oidc/pending_trusted_publisher_policy_test.rb @@ -0,0 +1,44 @@ +require "test_helper" + +class Admin::OIDC::PendingTrustedPublisherPolicyTest < AdminPolicyTestCase + setup do + @pending_trusted_publisher = create(:oidc_pending_trusted_publisher) + + @admin = create(:admin_github_user, :is_admin) + @non_admin = create(:admin_github_user) + end + + def test_scope + assert_equal [@pending_trusted_publisher], policy_scope!( + @admin, + OIDC::PendingTrustedPublisher + ).to_a + end + + def test_avo_index + assert_authorizes @admin, OIDC::PendingTrustedPublisher, :avo_index? + + refute_authorizes @non_admin, OIDC::PendingTrustedPublisher, :avo_index? + end + + def test_avo_show + assert_authorizes @admin, @pending_trusted_publisher, :avo_show? + + refute_authorizes @non_admin, @pending_trusted_publisher, :avo_show? + end + + def test_avo_create + refute_authorizes @admin, OIDC::PendingTrustedPublisher, :avo_create? + refute_authorizes @non_admin, OIDC::PendingTrustedPublisher, :avo_create? + end + + def test_avo_update + refute_authorizes @admin, @pending_trusted_publisher, :avo_update? + refute_authorizes @non_admin, @pending_trusted_publisher, :avo_update? + end + + def test_avo_destroy + refute_authorizes @admin, @pending_trusted_publisher, :avo_destroy? + refute_authorizes @non_admin, @pending_trusted_publisher, :avo_destroy? + end +end diff --git a/test/policies/admin/oidc/provider_policy_test.rb b/test/policies/admin/oidc/provider_policy_test.rb new file mode 100644 index 00000000000..46d74157382 --- /dev/null +++ b/test/policies/admin/oidc/provider_policy_test.rb @@ -0,0 +1,46 @@ +require "test_helper" + +class Admin::OIDC::ProviderPolicyTest < AdminPolicyTestCase + setup do + @provider = FactoryBot.create(:oidc_provider) + + @admin = FactoryBot.create(:admin_github_user, :is_admin) + @non_admin = FactoryBot.create(:admin_github_user) + end + + def test_scope + assert_equal [@provider], policy_scope!( + @admin, + OIDC::Provider + ).to_a + end + + def test_avo_index + assert_authorizes @admin, OIDC::Provider, :avo_index? + + refute_authorizes @non_admin, OIDC::Provider, :avo_index? + end + + def test_avo_show + assert_authorizes @admin, @provider, :avo_show? + + refute_authorizes @non_admin, @provider, :avo_show? + end + + def test_avo_create + assert_authorizes @admin, OIDC::Provider, :avo_create? + + refute_authorizes @non_admin, OIDC::Provider, :avo_create? + end + + def test_avo_update + assert_authorizes @admin, @provider, :avo_update? + + refute_authorizes @non_admin, @provider, :avo_update? + end + + def test_avo_destroy + refute_authorizes @admin, @provider, :avo_destroy? + refute_authorizes @non_admin, @provider, :avo_destroy? + end +end diff --git a/test/policies/admin/oidc/rubygem_trusted_publisher_policy_test.rb b/test/policies/admin/oidc/rubygem_trusted_publisher_policy_test.rb new file mode 100644 index 00000000000..87b4d5f18d9 --- /dev/null +++ b/test/policies/admin/oidc/rubygem_trusted_publisher_policy_test.rb @@ -0,0 +1,44 @@ +require "test_helper" + +class Admin::OIDC::RubygemTrustedPublisherPolicyTest < AdminPolicyTestCase + setup do + @rubygem_trusted_publisher = create(:oidc_rubygem_trusted_publisher) + + @admin = create(:admin_github_user, :is_admin) + @non_admin = create(:admin_github_user) + end + + def test_scope + assert_equal [@rubygem_trusted_publisher], policy_scope!( + @admin, + OIDC::RubygemTrustedPublisher + ).to_a + end + + def test_avo_index + assert_authorizes @admin, OIDC::RubygemTrustedPublisher, :avo_index? + + refute_authorizes @non_admin, OIDC::RubygemTrustedPublisher, :avo_index? + end + + def test_avo_show + assert_authorizes @admin, @rubygem_trusted_publisher, :avo_show? + + refute_authorizes @non_admin, @rubygem_trusted_publisher, :avo_show? + end + + def test_avo_create + refute_authorizes @admin, OIDC::RubygemTrustedPublisher, :avo_create? + refute_authorizes @non_admin, OIDC::RubygemTrustedPublisher, :avo_create? + end + + def test_avo_update + refute_authorizes @admin, @rubygem_trusted_publisher, :avo_update? + refute_authorizes @non_admin, @rubygem_trusted_publisher, :avo_update? + end + + def test_avo_destroy + refute_authorizes @admin, @rubygem_trusted_publisher, :avo_destroy? + refute_authorizes @non_admin, @rubygem_trusted_publisher, :avo_destroy? + end +end diff --git a/test/policies/admin/oidc/trusted_publisher/github_action_policy_test.rb b/test/policies/admin/oidc/trusted_publisher/github_action_policy_test.rb new file mode 100644 index 00000000000..64e6249da70 --- /dev/null +++ b/test/policies/admin/oidc/trusted_publisher/github_action_policy_test.rb @@ -0,0 +1,44 @@ +require "test_helper" + +class Admin::OIDC::TrustedPublisher::GitHubActionPolicyTest < AdminPolicyTestCase + setup do + @trusted_publisher_github_action = create(:oidc_trusted_publisher_github_action) + + @admin = create(:admin_github_user, :is_admin) + @non_admin = create(:admin_github_user) + end + + def test_scope + assert_equal [@trusted_publisher_github_action], policy_scope!( + @admin, + OIDC::TrustedPublisher::GitHubAction + ).to_a + end + + def test_avo_index + assert_authorizes @admin, OIDC::TrustedPublisher::GitHubAction, :avo_index? + + refute_authorizes @non_admin, OIDC::TrustedPublisher::GitHubAction, :avo_index? + end + + def test_avo_show + assert_authorizes @admin, @trusted_publisher_github_action, :avo_show? + + refute_authorizes @non_admin, @trusted_publisher_github_action, :avo_show? + end + + def test_avo_create + refute_authorizes @admin, OIDC::TrustedPublisher::GitHubAction, :avo_create? + refute_authorizes @non_admin, OIDC::TrustedPublisher::GitHubAction, :avo_create? + end + + def test_avo_update + refute_authorizes @admin, @trusted_publisher_github_action, :avo_update? + refute_authorizes @non_admin, @trusted_publisher_github_action, :avo_update? + end + + def test_avo_destroy + refute_authorizes @admin, @trusted_publisher_github_action, :avo_destroy? + refute_authorizes @non_admin, @trusted_publisher_github_action, :avo_destroy? + end +end diff --git a/test/policies/admin/organization_policy_test.rb b/test/policies/admin/organization_policy_test.rb new file mode 100644 index 00000000000..2bda6ccfc65 --- /dev/null +++ b/test/policies/admin/organization_policy_test.rb @@ -0,0 +1,38 @@ +require "test_helper" + +class Admin::OrganizationPolicyTest < AdminPolicyTestCase + setup do + @organization = FactoryBot.create(:organization) + @admin = FactoryBot.create(:admin_github_user, :is_admin) + @non_admin = FactoryBot.create(:admin_github_user) + end + + def test_scope + assert_equal [@organization], policy_scope!(@admin, Organization).to_a + end + + def test_show + assert_authorizes @admin, @organization, :avo_show? + refute_authorizes @non_admin, @organization, :avo_show? + end + + def test_create + refute_authorizes @admin, @organization, :avo_create? + refute_authorizes @non_admin, @organization, :avo_create? + end + + def test_update + refute_authorizes @admin, @organization, :avo_update? + refute_authorizes @non_admin, @organization, :avo_update? + end + + def test_destroy + refute_authorizes @admin, @organization, :avo_destroy? + refute_authorizes @non_admin, @organization, :avo_destroy? + end + + def test_search + assert_authorizes @admin, @organization, :avo_search? + refute_authorizes @non_admin, @organization, :avo_search? + end +end diff --git a/test/policies/admin/ownership_policy_test.rb b/test/policies/admin/ownership_policy_test.rb new file mode 100644 index 00000000000..1b43cfcde18 --- /dev/null +++ b/test/policies/admin/ownership_policy_test.rb @@ -0,0 +1,39 @@ +require "test_helper" + +class Admin::OwnershipPolicyTest < AdminPolicyTestCase + setup do + @ownership = FactoryBot.create(:ownership) + @admin = FactoryBot.create(:admin_github_user, :is_admin) + @non_admin = FactoryBot.create(:admin_github_user) + end + + def test_scope + assert_equal [@ownership], policy_scope!(@admin, Ownership).to_a + end + + def test_avo_index + refute_authorizes @admin, Ownership, :avo_index? + refute_authorizes @non_admin, Ownership, :avo_index? + end + + def test_avo_show + assert_authorizes @admin, @ownership, :avo_show? + + refute_authorizes @non_admin, @ownership, :avo_show? + end + + def test_avo_create + refute_authorizes @admin, Ownership, :avo_create? + refute_authorizes @non_admin, Ownership, :avo_create? + end + + def test_avo_update + refute_authorizes @admin, @ownership, :avo_update? + refute_authorizes @non_admin, @ownership, :avo_update? + end + + def test_avo_destroy + refute_authorizes @admin, @ownership, :avo_destroy? + refute_authorizes @non_admin, @ownership, :avo_destroy? + end +end diff --git a/test/policies/admin/rubygem_policy_test.rb b/test/policies/admin/rubygem_policy_test.rb new file mode 100644 index 00000000000..95cd3b67462 --- /dev/null +++ b/test/policies/admin/rubygem_policy_test.rb @@ -0,0 +1,28 @@ +require "test_helper" + +class Admin::RubygemPolicyTest < AdminPolicyTestCase + setup do + @admin = FactoryBot.create(:admin_github_user, :is_admin) + @non_admin = FactoryBot.create(:admin_github_user) + @rubygem = FactoryBot.create(:rubygem) + end + + def test_scope + assert_equal [@rubygem], policy_scope!( + @admin, + Rubygem + ).to_a + end + + def test_avo_index + assert_authorizes @admin, Rubygem, :avo_index? + + refute_authorizes @non_admin, Rubygem, :avo_index? + end + + def test_avo_show + assert_authorizes @admin, @rubygem, :avo_show? + + refute_authorizes @non_admin, @rubygem, :avo_show? + end +end diff --git a/test/policies/admin/sendgrid_event_policy_test.rb b/test/policies/admin/sendgrid_event_policy_test.rb new file mode 100644 index 00000000000..a51fb48797c --- /dev/null +++ b/test/policies/admin/sendgrid_event_policy_test.rb @@ -0,0 +1,47 @@ +require "test_helper" + +class Admin::SendgridEventPolicyTest < AdminPolicyTestCase + setup do + @sendgrid_event = FactoryBot.create(:sendgrid_event) + @admin = FactoryBot.create(:admin_github_user, :is_admin) + @non_admin = FactoryBot.create(:admin_github_user) + end + + def test_scope + assert_equal [@sendgrid_event], policy_scope!( + @admin, + SendgridEvent + ).to_a + end + + def test_avo_index + refute_authorizes @admin, ApiKey, :avo_index? + refute_authorizes @non_admin, ApiKey, :avo_index? + end + + def test_avo_show + assert_authorizes @admin, @sendgrid_event, :avo_show? + + refute_authorizes @non_admin, @sendgrid_event, :avo_show? + end + + def test_avo_create + refute_authorizes @admin, ApiKey, :avo_create? + refute_authorizes @non_admin, ApiKey, :avo_create? + end + + def test_avo_update + refute_authorizes @admin, @sendgrid_event, :avo_update? + refute_authorizes @non_admin, @sendgrid_event, :avo_update? + end + + def test_avo_destroy + refute_authorizes @admin, @sendgrid_event, :avo_destroy? + refute_authorizes @non_admin, @sendgrid_event, :avo_destroy? + end + + def test_act_on + refute_authorizes @admin, @sendgrid_event, :act_on? + refute_authorizes @non_admin, @sendgrid_event, :act_on? + end +end diff --git a/test/policies/admin/user_policy_test.rb b/test/policies/admin/user_policy_test.rb new file mode 100644 index 00000000000..019cfb33313 --- /dev/null +++ b/test/policies/admin/user_policy_test.rb @@ -0,0 +1,14 @@ +require "test_helper" + +class Admin::UserPolicyTest < AdminPolicyTestCase + setup do + @user = FactoryBot.create(:user) + @admin = FactoryBot.create(:admin_github_user, :is_admin) + @non_admin = FactoryBot.create(:admin_github_user) + end + + def test_search + assert_authorizes @admin, @user, :avo_search? + refute_authorizes @non_admin, @user, :avo_search? + end +end diff --git a/test/policies/admin/web_hook_policy_test.rb b/test/policies/admin/web_hook_policy_test.rb new file mode 100644 index 00000000000..39a75876304 --- /dev/null +++ b/test/policies/admin/web_hook_policy_test.rb @@ -0,0 +1,48 @@ +require "test_helper" + +class Admin::WebHookPolicyTest < AdminPolicyTestCase + setup do + @web_hook = FactoryBot.create(:web_hook) + @admin = FactoryBot.create(:admin_github_user, :is_admin) + @non_admin = FactoryBot.create(:admin_github_user) + end + + def test_scope + assert_equal [@web_hook], policy_scope!( + @admin, + WebHook + ).to_a + end + + def test_avo_index + refute_authorizes @admin, ApiKey, :avo_index? + refute_authorizes @non_admin, ApiKey, :avo_index? + end + + def test_avo_show + assert_authorizes @admin, @web_hook, :avo_show? + + refute_authorizes @non_admin, @web_hook, :avo_show? + end + + def test_avo_create + refute_authorizes @admin, ApiKey, :avo_create? + refute_authorizes @non_admin, ApiKey, :avo_create? + end + + def test_avo_update + refute_authorizes @admin, @web_hook, :avo_update? + refute_authorizes @non_admin, @web_hook, :avo_update? + end + + def test_avo_destroy + refute_authorizes @admin, @web_hook, :avo_destroy? + refute_authorizes @non_admin, @web_hook, :avo_destroy? + end + + def test_act_on + assert_authorizes @admin, @web_hook, :act_on? + + refute_authorizes @non_admin, @web_hook, :act_on? + end +end diff --git a/test/policies/admin/webauthn_credential_policy_test.rb b/test/policies/admin/webauthn_credential_policy_test.rb new file mode 100644 index 00000000000..4d3370bd927 --- /dev/null +++ b/test/policies/admin/webauthn_credential_policy_test.rb @@ -0,0 +1,42 @@ +require "test_helper" + +class Admin::WebauthnCredentialPolicyTest < AdminPolicyTestCase + setup do + @webauthn_credential = FactoryBot.create(:webauthn_credential) + @admin = FactoryBot.create(:admin_github_user, :is_admin) + @non_admin = FactoryBot.create(:admin_github_user) + end + + def test_scope + assert_equal [@webauthn_credential], policy_scope!( + @admin, + WebauthnCredential + ).to_a + end + + def test_avo_index + refute_authorizes @admin, WebauthnCredential, :avo_index? + refute_authorizes @non_admin, WebauthnCredential, :avo_index? + end + + def test_avo_show + assert_authorizes @admin, @webauthn_credential, :avo_show? + + refute_authorizes @non_admin, @webauthn_credential, :avo_show? + end + + def test_avo_create + refute_authorizes @admin, WebauthnCredential, :avo_create? + refute_authorizes @non_admin, WebauthnCredential, :avo_create? + end + + def test_avo_update + refute_authorizes @admin, @webauthn_credential, :avo_update? + refute_authorizes @non_admin, @webauthn_credential, :avo_update? + end + + def test_avo_destroy + refute_authorizes @admin, @webauthn_credential, :avo_destroy? + refute_authorizes @non_admin, @webauthn_credential, :avo_destroy? + end +end diff --git a/test/policies/admin/webauthn_verification_policy_test.rb b/test/policies/admin/webauthn_verification_policy_test.rb new file mode 100644 index 00000000000..e4790c02e0e --- /dev/null +++ b/test/policies/admin/webauthn_verification_policy_test.rb @@ -0,0 +1,42 @@ +require "test_helper" + +class Admin::WebauthnVerificationPolicyTest < AdminPolicyTestCase + setup do + @webauthn_verification = FactoryBot.create(:webauthn_verification) + @admin = FactoryBot.create(:admin_github_user, :is_admin) + @non_admin = FactoryBot.create(:admin_github_user) + end + + def test_scope + assert_equal [@webauthn_verification], policy_scope!( + @admin, + WebauthnVerification + ).to_a + end + + def test_avo_index + refute_authorizes @admin, WebauthnVerification, :avo_index? + refute_authorizes @non_admin, WebauthnVerification, :avo_index? + end + + def test_avo_show + assert_authorizes @admin, @webauthn_verification, :avo_show? + + refute_authorizes @non_admin, @webauthn_verification, :avo_show? + end + + def test_avo_create + refute_authorizes @admin, WebauthnVerification, :avo_create? + refute_authorizes @non_admin, WebauthnVerification, :avo_create? + end + + def test_avo_update + refute_authorizes @admin, @webauthn_verification, :avo_update? + refute_authorizes @non_admin, @webauthn_verification, :avo_update? + end + + def test_avo_destroy + refute_authorizes @admin, @webauthn_verification, :avo_destroy? + refute_authorizes @non_admin, @webauthn_verification, :avo_destroy? + end +end diff --git a/test/policies/api/ownership_policy_test.rb b/test/policies/api/ownership_policy_test.rb new file mode 100644 index 00000000000..d0641f6533e --- /dev/null +++ b/test/policies/api/ownership_policy_test.rb @@ -0,0 +1,31 @@ +require "test_helper" + +class Api::OwnershipPolicyTest < ApiPolicyTestCase + setup do + @owner = create(:user, handle: "owner") + @user = create(:user, handle: "user") + @rubygem = create(:rubygem, owners: [@owner]) + + @record = create(:ownership, rubygem: @rubygem, user: @user, authorizer: @owner) + + OwnershipPolicy.any_instance.stubs( + update?: true, + destroy?: true + ) + end + + def policy!(api_key) + Pundit.policy!(api_key, [:api, @record]) + end + + context "#update?" do + scope = :update_owner + action = :update? + + should_require_user_key scope, action + should_require_mfa scope, action + should_require_scope scope, action + should_require_rubygem_scope scope, action + should_delegate_to_policy scope, action, OwnershipPolicy + end +end diff --git a/test/policies/api/rubygem_policy_test.rb b/test/policies/api/rubygem_policy_test.rb new file mode 100644 index 00000000000..fd4e0cff8b8 --- /dev/null +++ b/test/policies/api/rubygem_policy_test.rb @@ -0,0 +1,85 @@ +require "test_helper" + +class Api::RubygemPolicyTest < ApiPolicyTestCase + setup do + @owner = create(:user, handle: "owner") + @rubygem = create(:rubygem, owners: [@owner]) + + RubygemPolicy.any_instance.stubs( + configure_trusted_publishers?: true, + add_owner?: true, + remove_owner?: true + ) + end + + def policy!(api_key) + Pundit.policy!(api_key, [:api, @rubygem]) + end + + context "#index?" do + scope = :index_rubygems + action = :index? + + should_require_scope scope, action + + should "allow ApiKey with scope and any rubygem" do + api_key = key_with_scope([:push_rubygem, scope], rubygem: create(:rubygem, owners: [@owner])) + + assert_authorized api_key, action + end + end + + context "#create?" do + scope = :push_rubygem + action = :create? + + should_require_scope scope, action + should_require_mfa scope, action + + should "deny ApiKey without scope but with rubygem" do + refute_authorized key_without_scope(:push_rubygem, rubygem: @rubygem), :create? + end + end + + context "#yank?" do + scope = :yank_rubygem + action = :yank? + + should_require_user_key scope, action + should_require_scope scope, action + should_require_rubygem_scope scope, action + end + + context "#configure_trusted_publishers?" do + scope = :configure_trusted_publishers + action = :configure_trusted_publishers? + + should_require_user_key scope, action + should_require_mfa scope, action + should_require_scope scope, action + should_require_rubygem_scope scope, action + should_delegate_to_policy scope, action, RubygemPolicy + end + + context "#add_owner" do + scope = :add_owner + action = :add_owner? + + should_require_user_key scope, action + should_require_mfa scope, action + should_require_scope scope, action + should_require_rubygem_scope scope, action + should_delegate_to_policy scope, action, RubygemPolicy + end + + context "#remove_owner" do + scope = :remove_owner + action = :remove_owner? + + should_require_user_key scope, action + should_require_mfa scope, action + should_require_scope scope, action + should_require_rubygem_scope scope, action + should_delegate_to_policy scope, action, RubygemPolicy + end +end diff --git a/test/policies/events/rubygem_event_policy_test.rb b/test/policies/events/rubygem_event_policy_test.rb new file mode 100644 index 00000000000..6d2460615b1 --- /dev/null +++ b/test/policies/events/rubygem_event_policy_test.rb @@ -0,0 +1,34 @@ +require "test_helper" + +class Events::RubygemEventPolicyTest < ActiveSupport::TestCase + setup do + @owner = FactoryBot.create(:user) + @rubygem = FactoryBot.create(:rubygem, owners: [@owner]) + @event = @rubygem.events.last # rubygem:owner:added + @user = FactoryBot.create(:user) + end + + def test_show + assert_predicate Pundit.policy!(@owner, @event), :show? + refute_predicate Pundit.policy!(@user, @event), :show? + refute_predicate Pundit.policy!(nil, @event), :show? + end + + def test_create + refute_predicate Pundit.policy!(@owner, Events::RubygemEvent), :create? + refute_predicate Pundit.policy!(@user, Events::RubygemEvent), :create? + refute_predicate Pundit.policy!(nil, Events::RubygemEvent), :create? + end + + def test_update + refute_predicate Pundit.policy!(@owner, @event), :update? + refute_predicate Pundit.policy!(@user, @event), :update? + refute_predicate Pundit.policy!(nil, @event), :update? + end + + def test_destroy + refute_predicate Pundit.policy!(@owner, @event), :destroy? + refute_predicate Pundit.policy!(@user, @event), :destroy? + refute_predicate Pundit.policy!(nil, @event), :destroy? + end +end diff --git a/test/policies/oidc/pending_trusted_publisher_policy_test.rb b/test/policies/oidc/pending_trusted_publisher_policy_test.rb new file mode 100644 index 00000000000..523c8d9728f --- /dev/null +++ b/test/policies/oidc/pending_trusted_publisher_policy_test.rb @@ -0,0 +1,41 @@ +require "test_helper" + +class OIDC::PendingTrustedPublisherPolicyTest < ActiveSupport::TestCase + setup do + @owner = create(:user) + @trusted_publisher = create(:oidc_pending_trusted_publisher, rubygem_name: "pending-gem-name", user: @owner) + @user = create(:user) + end + + def test_scope + assert_same_elements( + [@trusted_publisher], + Pundit.policy_scope!(@owner, OIDC::PendingTrustedPublisher).to_a + ) + assert_same_elements( + [@trusted_publisher], + Pundit.policy_scope!(@owner, @owner.oidc_pending_trusted_publishers).to_a + ) + + assert_empty Pundit.policy_scope!(@user, OIDC::PendingTrustedPublisher).to_a + assert_empty Pundit.policy_scope!(@user, @user.oidc_pending_trusted_publishers).to_a + end + + def test_show + assert_predicate Pundit.policy!(@owner, @trusted_publisher), :show? + refute_predicate Pundit.policy!(@user, @trusted_publisher), :show? + refute_predicate Pundit.policy!(nil, @trusted_publisher), :show? + end + + def test_create + assert_predicate Pundit.policy!(@owner, @trusted_publisher), :create? + refute_predicate Pundit.policy!(@user, @trusted_publisher), :create? + refute_predicate Pundit.policy!(nil, @trusted_publisher), :create? + end + + def test_destroy + assert_predicate Pundit.policy!(@owner, @trusted_publisher), :destroy? + refute_predicate Pundit.policy!(@user, @trusted_publisher), :destroy? + refute_predicate Pundit.policy!(nil, @trusted_publisher), :destroy? + end +end diff --git a/test/policies/organization_policy_test.rb b/test/policies/organization_policy_test.rb new file mode 100644 index 00000000000..d65ac86769a --- /dev/null +++ b/test/policies/organization_policy_test.rb @@ -0,0 +1,86 @@ +require "test_helper" +class OrganisationPolicyTest < PolicyTestCase + setup do + @owner = create(:user) + @admin = create(:user) + @maintainer = create(:user) + @guest = create(:user) + @organization = create(:organization, owners: [@owner], admins: [@admin], maintainers: [@maintainer]) + end + + def policy!(user) + Pundit.policy!(user, @organization) + end + + context "#update?" do + should "only be authorized if the user is an owner" do + assert_authorized @owner, :update? + assert_authorized @admin, :update? + refute_authorized @maintainer, :update? + refute_authorized @guest, :update? + end + end + + context "add_gem?" do + should "only be authorized if the user is an owner" do + assert_authorized @owner, :add_gem? + assert_authorized @admin, :add_gem? + refute_authorized @maintainer, :add_gem? + refute_authorized @guest, :add_gem? + end + end + + context "#remove_gem?" do + should "only be authorized if the user is an owner" do + assert_authorized @owner, :remove_gem? + assert_authorized @admin, :remove_gem? + refute_authorized @maintainer, :remove_gem? + refute_authorized @guest, :remove_gem? + end + end + + context "#add_membership?" do + should "only be authorized if the user is an owner" do + assert_authorized @owner, :add_membership? + assert_authorized @admin, :add_membership? + refute_authorized @maintainer, :add_membership? + refute_authorized @guest, :add_membership? + end + end + + context "#update_membership?" do + should "only be authorized if the user is an owner" do + assert_authorized @owner, :update_membership? + assert_authorized @admin, :update_membership? + refute_authorized @maintainer, :update_membership? + refute_authorized @guest, :update_membership? + end + end + + context "#remove_membership?" do + should "only be authorized if the user is an owner" do + assert_authorized @owner, :remove_membership? + assert_authorized @admin, :remove_membership? + refute_authorized @maintainer, :remove_membership? + refute_authorized @guest, :remove_membership? + end + end + + context "#show_membership?" do + should "only be authorized if the user is an owner or maintainer" do + assert_authorized @owner, :show_membership? + assert_authorized @admin, :show_membership? + assert_authorized @maintainer, :show_membership? + refute_authorized @guest, :show_membership? + end + end + + context "#list_memberships?" do + should "only be authorized if the user is an owner or maintainer" do + assert_authorized @owner, :list_memberships? + assert_authorized @admin, :list_memberships? + assert_authorized @maintainer, :list_memberships? + refute_authorized @guest, :list_memberships? + end + end +end diff --git a/test/policies/ownership_call_policy_test.rb b/test/policies/ownership_call_policy_test.rb new file mode 100644 index 00000000000..9bd3c3c93a9 --- /dev/null +++ b/test/policies/ownership_call_policy_test.rb @@ -0,0 +1,25 @@ +require "test_helper" + +class OwnershipCallPolicyTest < PolicyTestCase + setup do + @owner = create(:user) + @rubygem = create(:rubygem, owners: [@owner]) + @ownership_call = @rubygem.ownership_calls.create(user: @owner, note: "valid note") + + @user = create(:user) + end + + def policy!(user) + Pundit.policy!(user, @ownership_call) + end + + def test_create + assert_authorized @owner, :create? + refute_authorized @user, :create? + end + + def test_close + assert_authorized @owner, :close? + refute_authorized @user, :close? + end +end diff --git a/test/policies/ownership_policy_test.rb b/test/policies/ownership_policy_test.rb new file mode 100644 index 00000000000..2fa68b75d37 --- /dev/null +++ b/test/policies/ownership_policy_test.rb @@ -0,0 +1,42 @@ +require "test_helper" + +class OwnershipPolicyTest < ActiveSupport::TestCase + setup do + @authorizer = FactoryBot.create(:user, handle: "owner") + @maintainer = FactoryBot.create(:user, handle: "maintainer") + @rubygem = FactoryBot.create(:rubygem, owners: [@authorizer], maintainers: [@maintainer]) + @authorizer_ownership = @rubygem.ownerships.first + @maintainer_ownership = @maintainer.ownerships.first + @unconfirmed_ownership = FactoryBot.build(:ownership, :unconfirmed, rubygem: @rubygem, authorizer: @authorizer) + @unconfirmed_maintainer_ownership = FactoryBot.build(:ownership, :maintainer, rubygem: @rubygem, authorizer: @authorizer) + + @invited = @unconfirmed_ownership.user + @user = FactoryBot.create(:user) + end + + def test_create + assert_predicate Pundit.policy!(@authorizer, @unconfirmed_ownership), :create? + refute_predicate Pundit.policy!(@invited, @unconfirmed_ownership), :create? + refute_predicate Pundit.policy!(@user, @unconfirmed_ownership), :create? + refute_predicate Pundit.policy!(@maintainer, @maintainer_ownership), :create? + refute_predicate Pundit.policy!(@maintainer, @unconfirmed_maintainer_ownership), :create? + end + + def test_update + assert_predicate Pundit.policy!(@authorizer, @maintainer_ownership), :update? + refute_predicate Pundit.policy!(@authorizer, @authorizer_ownership), :update? + refute_predicate Pundit.policy!(@invited, @unconfirmed_ownership), :update? + refute_predicate Pundit.policy!(@user, @unconfirmed_ownership), :update? + refute_predicate Pundit.policy!(@user, @unconfirmed_maintainer_ownership), :update? + refute_predicate Pundit.policy!(@maintainer, @maintainer_ownership), :update? + refute_predicate Pundit.policy!(@maintainer, @unconfirmed_maintainer_ownership), :update? + end + + def test_destroy + assert_predicate Pundit.policy!(@authorizer, @authorizer_ownership), :destroy? + refute_predicate Pundit.policy!(@maintainer, @authorizer_ownership), :destroy? + refute_predicate Pundit.policy!(@user, @authorizer_ownership), :destroy? + refute_predicate Pundit.policy!(@user, @maintainer_ownership), :destroy? + refute_predicate Pundit.policy!(@user, @unconfirmed_maintainer_ownership), :destroy? + end +end diff --git a/test/policies/ownership_request_policy_test.rb b/test/policies/ownership_request_policy_test.rb new file mode 100644 index 00000000000..ae598a5f3d6 --- /dev/null +++ b/test/policies/ownership_request_policy_test.rb @@ -0,0 +1,55 @@ +require "test_helper" + +class OwnershipRequestPolicyTest < ActiveSupport::TestCase + setup do + @user = create(:user, handle: "user") + @owner = create(:user, handle: "owner") + @requester = create(:user, handle: "requester") + + @rubygem = create(:rubygem, number: "1.0", owners: [@owner], created_at: 2.years.ago) + @rubygem.versions.last.update!(created_at: 2.years.ago) + + # ensure it is possible to request ownership of the rubygem + assert_predicate Pundit.policy!(@requester, @rubygem), :request_ownership? + @ownership_request = create(:ownership_request, rubygem: @rubygem, user: @requester) + end + + context "#create?" do + should "allow the requester to create when the gem is considered abandoned" do + assert_predicate Pundit.policy!(@requester, @ownership_request), :create? + refute_predicate Pundit.policy!(@owner, @ownership_request), :create? + refute_predicate Pundit.policy!(@user, @ownership_request), :create? + end + + should "not allow the requester to create when the gem is not considered abandoned" do + newgem = create(:rubygem, number: "1.0", owners: [@owner]) + newgem_request = build(:ownership_request, rubygem: newgem, user: @requester) + + refute_predicate Pundit.policy!(@requester, newgem_request), :create? + refute_predicate Pundit.policy!(@owner, newgem_request), :create? + refute_predicate Pundit.policy!(@user, newgem_request), :create? + end + end + + context "#approve?" do + should "only allow the owner to approve" do + refute_predicate Pundit.policy!(@requester, @ownership_request), :approve? + assert_predicate Pundit.policy!(@owner, @ownership_request), :approve? + refute_predicate Pundit.policy!(@user, @ownership_request), :approve? + end + end + + context "#close?" do + should "allow the requester to close" do + assert_predicate Pundit.policy!(@requester, @ownership_request), :close? + end + + should "allow the owner to close" do + assert_predicate Pundit.policy!(@owner, @ownership_request), :close? + end + + should "not allow other users to close" do + refute_predicate Pundit.policy!(@user, @ownership_request), :close? + end + end +end diff --git a/test/policies/rubygem_policy_test.rb b/test/policies/rubygem_policy_test.rb new file mode 100644 index 00000000000..40b29b2041e --- /dev/null +++ b/test/policies/rubygem_policy_test.rb @@ -0,0 +1,138 @@ +require "test_helper" + +class RubygemPolicyTest < PolicyTestCase + setup do + @owner = create(:user, handle: "owner") + @maintainer = create(:user, handle: "maintainer") + @rubygem = create(:rubygem, owners: [@owner], maintainers: [@maintainer]) + @user = create(:user, handle: "user") + end + + def policy!(user) + Pundit.policy!(user, @rubygem) + end + + context "#configure_oidc?" do + should "only allow the owner" do + assert_authorized @owner, :configure_oidc? + refute_authorized @user, :configure_oidc? + refute_authorized nil, :configure_oidc? + end + end + + context "#manage_adoption?" do + should "only allow the owner" do + assert_authorized @owner, :manage_adoption? + refute_authorized @user, :manage_adoption? + refute_authorized nil, :manage_adoption? + end + end + + context "#request_ownership?" do + should "be true if the gem has ownership calls" do + create(:ownership_call, rubygem: @rubygem, user: @owner) + + assert_authorized @user, :request_ownership? + end + + should "be false if the gem has more than 10,000 downloads" do + @rubygem = create(:rubygem, owners: [@owner], downloads: 10_001) + create(:version, rubygem: @rubygem, created_at: 2.years.ago) + + assert_operator @rubygem.downloads, :>, RubygemPolicy::ABANDONED_DOWNLOADS_MAX + refute_authorized @user, :request_ownership? + end + + should "be false if the gem has no versions" do + assert_empty @rubygem.versions + refute_authorized @user, :request_ownership? + end + + should "be false if the gem has a version newer than 1 year" do + create(:version, rubygem: @rubygem, created_at: 11.months.ago) + + refute_authorized @user, :request_ownership? + end + + should "be true if the gem's latest version is older than 1 year and less than 10,000 downloads" do + create(:version, rubygem: @rubygem, created_at: 2.years.ago) + + assert_authorized @user, :request_ownership? + end + end + + context "#close_ownership_requests" do + should "only allow the owner to close ownership requests" do + assert_authorized @owner, :close_ownership_requests? + refute_authorized @maintainer, :close_ownership_requests? + refute_authorized @user, :close_ownership_requests? + end + end + + context "#show_adoption?" do + should "be true if the gem is owned by the user" do + assert_authorized @owner, :show_adoption? + refute_authorized @maintainer, :show_adoption? + end + + should "be true if the rubygem is adoptable" do + create(:version, rubygem: @rubygem, created_at: 2.years.ago) + + assert_authorized @user, :show_adoption? + end + end + + context "#show_events?" do + should "only allow the owner" do + assert_authorized @owner, :show_events? + assert_authorized @maintainer, :show_events? + refute_authorized @user, :show_events? + refute_authorized nil, :show_events? + end + end + + context "#configure_trusted_publishers?" do + should "only allow the owner" do + assert_authorized @owner, :configure_trusted_publishers? + refute_authorized @maintainer, :configure_trusted_publishers? + refute_authorized @user, :configure_trusted_publishers? + refute_authorized nil, :configure_trusted_publishers? + end + end + + context "#show_unconfirmed_ownerships?" do + should "only allow the owner" do + assert_authorized @owner, :show_unconfirmed_ownerships? + refute_authorized @maintainer, :show_unconfirmed_ownerships? + refute_authorized @user, :show_unconfirmed_ownerships? + refute_authorized nil, :show_unconfirmed_ownerships? + end + end + + context "#add_owner?" do + should "only allow the owner" do + assert_authorized @owner, :add_owner? + refute_authorized @maintainer, :add_owner? + refute_authorized @user, :add_owner? + refute_authorized nil, :add_owner? + end + end + + context "#update_owner?" do + should "only allow the owner" do + assert_authorized @owner, :update_owner? + refute_authorized @maintainer, :update_owner? + refute_authorized @user, :update_owner? + refute_authorized nil, :update_owner? + end + end + + context "#remove_owner?" do + should "only allow the owner" do + assert_authorized @owner, :remove_owner? + refute_authorized @maintainer, :remove_owner? + refute_authorized @user, :remove_owner? + refute_authorized nil, :remove_owner? + end + end +end diff --git a/test/policies/rubygem_trusted_publishing_policy_test.rb b/test/policies/rubygem_trusted_publishing_policy_test.rb new file mode 100644 index 00000000000..953044e4c7a --- /dev/null +++ b/test/policies/rubygem_trusted_publishing_policy_test.rb @@ -0,0 +1,42 @@ +require "test_helper" + +class RubygemTrustedPublishingPolicyTest < PolicyTestCase + setup do + @owner = create(:user, handle: "owner") + @maintainer = create(:user, handle: "maintainer") + @user = create(:user, handle: "user") + @rubygem = create(:rubygem, owners: [@owner], maintainers: [@maintainer]) + @rubygem_trusted_publisher = create(:oidc_rubygem_trusted_publisher, rubygem: @rubygem) + end + + def policy!(user) + Pundit.policy!(user, @rubygem_trusted_publisher) + end + + context "#show?" do + should "only allow the owner" do + assert_authorized @owner, :show? + refute_authorized @maintainer, :show? + refute_authorized @user, :show? + refute_authorized nil, :show? + end + end + + context "create?" do + should "only allow the owner" do + assert_authorized @owner, :create? + refute_authorized @maintainer, :create? + refute_authorized @user, :create? + refute_authorized nil, :create? + end + end + + context "#destroy?" do + should "only allow the owner" do + assert_authorized @owner, :destroy? + refute_authorized @maintainer, :destroy? + refute_authorized @user, :destroy? + refute_authorized nil, :destroy? + end + end +end diff --git a/test/safe_navigation_cop.rb b/test/safe_navigation_cop.rb deleted file mode 100644 index 39248800696..00000000000 --- a/test/safe_navigation_cop.rb +++ /dev/null @@ -1,18 +0,0 @@ -module RuboCop::Cop - class Style::CustomSafeNavigationCop < Cop - MSG = "Use ruby safe navigation opetator (&.) instead of try".freeze - - def_node_matcher :try_call?, <<-PATTERN - (send (...) :try (...)) - PATTERN - - def_node_matcher :try_bang_call?, <<-PATTERN - (send (...) :try! (...)) - PATTERN - - def on_send(node) - return unless try_call?(node) || try_bang_call?(node) - add_offense(node) - end - end -end diff --git a/test/system/admin_test.rb b/test/system/admin_test.rb new file mode 100644 index 00000000000..64f6f1a4dbf --- /dev/null +++ b/test/system/admin_test.rb @@ -0,0 +1,15 @@ +require "application_system_test_case" + +class AdminTest < ApplicationSystemTestCase + test "development login as an admin" do + @admin_user = create(:admin_github_user, :is_admin) + + visit "/admin" + + assert_content("Log in with GitHub") + assert_content(@admin_user.login) + click_link @admin_user.login + + assert_content("Welcome to the RubyGems.org admin dashboard!") + end +end diff --git a/test/system/advanced_search_test.rb b/test/system/advanced_search_test.rb new file mode 100644 index 00000000000..95775a32a6f --- /dev/null +++ b/test/system/advanced_search_test.rb @@ -0,0 +1,45 @@ +require "application_system_test_case" + +class AdvancedSearchTest < ApplicationSystemTestCase + include SearchKickHelper + + setup do + visit advanced_search_path + end + + test "searches for a gem while scoping advanced attributes" do + rubygem = create(:rubygem, name: "LDAP", number: "1.0.0", downloads: 3) + create(:version, summary: "some summary", description: "Hello World", rubygem: rubygem) + + import_and_refresh + + fill_in "query", with: "downloads: <5" + click_button "advanced_search_submit" + + assert_current_path(search_path, ignore_query: true) + assert has_content? "LDAP" + end + + test "enter inside any field will submit form" do + import_and_refresh + + ["#name", "#summary", "#description", "#downloads", "#updated"].each do |el| + visit advanced_search_path + find(el).send_keys(:return) + + assert_current_path(search_path, ignore_query: true) + end + end + + test "forms search query out of advanced attributes" do + import_and_refresh + + fill_in "name", with: "hello" + fill_in "summary", with: "world" + fill_in "description", with: "foo" + fill_in "downloads", with: ">69" + fill_in "updated", with: ">2021-05-05" + + assert has_field? "Search Gems…", with: "name: hello summary: world description: foo downloads: >69 updated: >2021-05-05" + end +end diff --git a/test/system/api_keys_test.rb b/test/system/api_keys_test.rb new file mode 100644 index 00000000000..8b56e88f9f6 --- /dev/null +++ b/test/system/api_keys_test.rb @@ -0,0 +1,377 @@ +require "application_system_test_case" + +class ApiKeysTest < ApplicationSystemTestCase + setup do + @user = create(:user) + @ownership = create(:ownership, user: @user, rubygem: create(:rubygem)) + + visit sign_in_path + fill_in "Email or Username", with: @user.email + fill_in "Password", with: @user.password + click_button "Sign in" + end + + test "creating new api key" do + visit_profile_api_keys_path + + assert_nil URI.parse(page.current_url).query + + fill_in "api_key[name]", with: "test" + check "api_key[index_rubygems]" + + refute page.has_content? "Enable MFA" + click_button "Create API Key" + + assert page.has_content? "Note that we won't be able to show the key to you again. New API key:" + assert_predicate @user.api_keys.last, :can_index_rubygems? + refute_predicate @user.api_keys.last, :mfa_enabled? + assert_nil @user.api_keys.last.rubygem + + assert_event Events::UserEvent::API_KEY_CREATED, { + name: "test", + scopes: ["index_rubygems"], + mfa: false, + api_key_gid: @user.api_keys.last.to_global_id.to_s + }, @user.events.where(tag: Events::UserEvent::API_KEY_CREATED).sole + end + + test "creating new api key from index" do + create(:api_key, owner: @user) + + visit_profile_api_keys_path + click_button "New API key" + + assert_empty URI.parse(page.current_url).query + + fill_in "api_key[name]", with: "test" + check "api_key[index_rubygems]" + + refute page.has_content? "Enable MFA" + click_button "Create API Key" + + assert page.has_content? "Note that we won't be able to show the key to you again. New API key:" + assert_predicate @user.api_keys.last, :can_index_rubygems? + refute_predicate @user.api_keys.last, :mfa_enabled? + assert_nil @user.api_keys.last.rubygem + end + + test "creating new api key scoped to a gem" do + visit_profile_api_keys_path + + fill_in "api_key[name]", with: "test" + check "api_key[push_rubygem]" + + assert page.has_select? "api_key_rubygem_id", selected: "All Gems" + page.select @ownership.rubygem.name + click_button "Create API Key" + + assert page.has_content? "Note that we won't be able to show the key to you again. New API key:" + assert_equal @ownership.rubygem.name, page.find('.owners__cell[data-title="Gem"]').text + assert_equal @ownership.rubygem, @user.api_keys.last.rubygem + end + + (ApiKey::API_SCOPES - ApiKey::APPLICABLE_GEM_API_SCOPES).each do |scope| + test "creating new api key cannot set gem scope with #{scope} scope selected" do + visit_profile_api_keys_path + check "api_key[#{scope}]" + + assert page.has_select? "api_key_rubygem_id", selected: "All Gems", disabled: true + end + end + + ApiKey::APPLICABLE_GEM_API_SCOPES.each do |scope| + test "creating new api key scoped to a gem with #{scope} scope" do + visit_profile_api_keys_path + fill_in "api_key[name]", with: "test" + check "api_key[#{scope}]" + + assert page.has_select? "api_key_rubygem_id", selected: "All Gems" + page.select @ownership.rubygem.name + click_button "Create API Key" + + assert page.has_content? "Note that we won't be able to show the key to you again. New API key:" + assert_equal @ownership.rubygem, @user.api_keys.last.rubygem + end + end + + test "selecting the exclusive scope deselects the other scopes and vice versa" do + visit_profile_api_keys_path + fill_in "api_key[name]", with: "test" + check "api_key[index_rubygems]" + check "api_key[push_rubygem]" + + assert page.has_select? "api_key_rubygem_id", selected: "All Gems" + + page.select @ownership.rubygem.name + + assert page.has_select? "api_key_rubygem_id", selected: @ownership.rubygem.name + + check "api_key[show_dashboard]" + + assert page.has_select? "api_key_rubygem_id", selected: "All Gems", disabled: true + assert page.has_unchecked_field? "api_key[index_rubygems]" + assert page.has_unchecked_field? "api_key[push_rubygem]" + + check "api_key[index_rubygems]" + + assert page.has_unchecked_field? "api_key[show_dashboard]" + + click_button "Create API Key" + + assert page.has_content? "Note that we won't be able to show the key to you again. New API key:" + assert_predicate @user.api_keys.last, :can_index_rubygems? + end + + test "creating new api key scoped to gem that the user does not own" do + visit_profile_api_keys_path + + fill_in "api_key[name]", with: "test" + check "api_key[push_rubygem]" + + assert page.has_select? "api_key_rubygem_id", selected: "All Gems" + page.select @ownership.rubygem.name + + @ownership.destroy! + click_button "Create API Key" + + assert page.has_css? ".flash" + assert page.has_content? "Rubygem must be a gem that you are an owner of" + assert_empty @user.api_keys + end + + test "creating new api key with MFA UI enabled" do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + + visit_profile_api_keys_path + + fill_in "api_key[name]", with: "test" + check "api_key[index_rubygems]" + check "mfa" + click_button "Create API Key" + + assert page.has_content? "Note that we won't be able to show the key to you again. New API key:" + assert_predicate @user.api_keys.last, :mfa_enabled? + end + + test "creating new api key with expiration" do + visit_profile_api_keys_path + + expiration = 1.day.from_now.beginning_of_minute + + fill_in "api_key[name]", with: "test" + check "api_key[index_rubygems]" + fill_in "api_key[expires_at]", with: expiration + click_button "Create API Key" + + assert_text "Note that we won't be able to show the key to you again. New API key:" + assert_equal expiration.strftime("%Y-%m-%d %H:%M %Z"), page.find('.owners__cell[data-title="Expiration"]').text + assert_equal expiration, @user.api_keys.last.expires_at + end + + test "creating new api key with MFA UI and API enabled" do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + + visit_profile_api_keys_path + + fill_in "api_key[name]", with: "test" + check "api_key[index_rubygems]" + click_button "Create API Key" + + assert page.has_content? "Note that we won't be able to show the key to you again. New API key:" + assert_predicate @user.api_keys.last, :mfa_enabled? + end + + test "update api key scope" do + api_key = create(:api_key, owner: @user) + + visit_profile_api_keys_path + click_button "Edit" + + assert_empty URI.parse(page.current_url).query + assert page.has_content? "Edit API key" + check "api_key[add_owner]" + + refute page.has_content? "Enable MFA" + click_button "Update API Key" + + assert_predicate api_key.reload, :can_add_owner? + end + + test "update api key gem scope" do + api_key = create(:api_key, scopes: %i[push_rubygem], owner: @user, ownership: @ownership) + + visit_profile_api_keys_path + click_button "Edit" + + assert page.has_content? "Edit API key" + assert page.has_select? "api_key_rubygem_id", selected: @ownership.rubygem.name + page.select "All Gems" + click_button "Update API Key" + + assert_equal "All Gems", page.find('.owners__cell[data-title="Gem"]').text + assert_nil api_key.reload.rubygem + end + + test "update gem scoped api key with applicable scopes removed" do + api_key = create(:api_key, scopes: %i[push_rubygem], owner: @user, ownership: @ownership) + + visit_profile_api_keys_path + click_button "Edit" + + assert page.has_content? "Edit API key" + page.check "api_key[index_rubygems]" + page.uncheck "api_key[push_rubygem]" + + assert page.has_select? "api_key_rubygem_id", selected: "All Gems", disabled: true + click_button "Update API Key" + + assert_nil api_key.reload.rubygem + end + + test "update gem scoped api key to another applicable scope" do + api_key = create(:api_key, scopes: %i[push_rubygem], owner: @user, ownership: @ownership) + + visit_profile_api_keys_path + click_button "Edit" + + assert page.has_content? "Edit API key" + page.uncheck "api_key[push_rubygem]" + + assert page.has_select? "api_key_rubygem_id", selected: "All Gems", disabled: true + + page.check "api_key[yank_rubygem]" + page.select @ownership.rubygem.name + click_button "Update API Key" + + assert_equal api_key.reload.rubygem, @ownership.rubygem + end + + test "update api key gem scope to a gem the user does not own" do + api_key = create(:api_key, scopes: %i[push_rubygem], owner: @user, ownership: @ownership) + @another_ownership = create(:ownership, user: @user, rubygem: create(:rubygem, name: "another_gem")) + + visit_profile_api_keys_path + click_button "Edit" + + assert page.has_content? "Edit API key" + assert page.has_select? "api_key_rubygem_id", selected: @ownership.rubygem.name + page.select "another_gem" + + @another_ownership.destroy! + click_button "Update API Key" + + assert page.has_css? ".flash" + assert page.has_content? "Rubygem must be a gem that you are an owner of" + assert_equal @ownership.rubygem, api_key.reload.rubygem + end + + test "update api key with MFA UI enabled" do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + + api_key = create(:api_key, owner: @user) + + visit_profile_api_keys_path + click_button "Edit" + + assert page.has_content? "Edit API key" + check "api_key[add_owner]" + check "mfa" + click_button "Update API Key" + + assert_predicate api_key.reload, :can_add_owner? + assert_predicate @user.api_keys.last, :mfa_enabled? + end + + test "update api key with MFA UI and API enabled" do + @user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + + api_key = create(:api_key, owner: @user) + + visit_profile_api_keys_path + click_button "Edit" + + assert page.has_content? "Edit API key" + check "api_key[add_owner]" + + refute page.has_content? "Enable MFA" + click_button "Update API Key" + + assert_predicate api_key.reload, :can_add_owner? + assert_predicate @user.api_keys.last, :mfa_enabled? + end + + test "disable expires_at field" do + _api_key = create(:api_key, owner: @user) + + visit_profile_api_keys_path + click_button "Edit" + + assert page.has_content? "Edit API key" + assert page.has_field? "api_key[expires_at]", disabled: true + end + + test "deleting api key" do + api_key = create(:api_key, owner: @user) + + visit_profile_api_keys_path + click_button "Delete" + + page.accept_alert + + assert page.has_content? "New API key" + page.assert_text "Successfully deleted API key: #{api_key.name}" + + assert_event Events::UserEvent::API_KEY_DELETED, { name: api_key.name, api_key_gid: api_key.to_global_id.to_s }, + @user.events.where(tag: Events::UserEvent::API_KEY_DELETED).sole + end + + test "deleting all api key" do + api_key = create(:api_key, owner: @user) + + visit_profile_api_keys_path + click_button "Reset" + + page.accept_alert + + assert page.has_content? "New API key" + page.assert_no_text api_key.name + end + + test "gem ownership removed displays api key as invalid" do + api_key = create(:api_key, scopes: %i[push_rubygem], owner: @user, ownership: @ownership) + visit_profile_api_keys_path + + refute page.has_css? ".owners__row__invalid" + + @ownership.destroy! + + visit_profile_api_keys_path + + assert page.has_css? ".owners__row__invalid" + assert_predicate api_key.reload, :soft_deleted? + + refute page.has_button? "Edit" + assert_equal "#{@ownership.rubygem.name} [?]", page.find('.owners__cell[data-title="Gem"]').text + visit_edit_profile_api_key_path(api_key) + + assert page.has_content? "An invalid API key cannot be edited. Please delete it and create a new one." + assert_equal profile_api_keys_path, page.current_path + end + + def visit_profile_api_keys_path + visit profile_api_keys_path + verify_password + end + + def visit_edit_profile_api_key_path(api_key) + visit edit_profile_api_key_path(api_key) + verify_password + end + + def verify_password + return unless page.has_css? "#verify_password_password" + + fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD + click_button "Confirm" + end +end diff --git a/test/system/autocompletes_test.rb b/test/system/autocompletes_test.rb new file mode 100644 index 00000000000..5fa88a50fe0 --- /dev/null +++ b/test/system/autocompletes_test.rb @@ -0,0 +1,89 @@ +require "application_system_test_case" + +class AutocompletesTest < ApplicationSystemTestCase + include SearchKickHelper + + setup do + rubygem = create(:rubygem, name: "rubocop") + create(:version, rubygem: rubygem, indexed: true) + rubygem = create(:rubygem, name: "rubocop-performance") + create(:version, rubygem: rubygem, indexed: true) + import_and_refresh + + visit root_path + @fill_field = find_by_id "query" + @fill_field.set "rubo" + page.find(".autocomplete-done", wait: Capybara.default_max_wait_time) + end + + test "search field" do + @fill_field.set "rubocop" + click_on class: "home__search__icon" + + assert page.has_content? "search" + assert page.has_content? "rubocop" + end + + test "selected field is only one with cursor selecting" do + find(".suggest-list").all("li").each(&:hover) # rubocop:disable Rails/FindEach + + assert_selector ".selected", count: 1 + end + + test "selected field is only one with arrow key selecting" do + @fill_field.native.send_keys :down + find ".selected" + @fill_field.native.send_keys :down + + assert_selector ".selected", count: 1 + end + + test "suggest list doesn't appear with gem not existing" do + @fill_field.set "ruxyz" + + assert_selector ".menu-item", count: 0 + end + + test "suggest list doesn't appear unless the search field is focused" do + find("h1").click + + assert_selector ".menu-item", count: 0 + end + + test "down arrow key to choose suggestion" do + @fill_field.native.send_keys :down + + assert page.has_no_field? "query", with: "rubo" + end + + test "up arrow key to choose suggestion" do + @fill_field.native.send_keys :up + + assert page.has_no_field? "query", with: "rubo" + end + + test "down arrow key should loop" do + @fill_field.native.send_keys :down, :down, :down, :down + + assert find(".suggest-list").all(".menu-item").last.matches_css?(".selected") + end + + test "up arrow key should loop" do + @fill_field.native.send_keys :up, :up, :up, :up + + assert find(".suggest-list").all(".menu-item").first.matches_css?(".selected") + end + + test "mouse hover a suggest item to choose suggestion" do + find("li", text: "rubocop", match: :first).hover + + assert_selector ".selected" + end + + test "mouse click a suggestion item to submit" do + find("li", text: "rubocop", match: :first).click + + assert_equal current_path, search_path || "/gems/" + assert page.has_content? "rubocop" + end +end diff --git a/test/system/avo/events_test.rb b/test/system/avo/events_test.rb new file mode 100644 index 00000000000..1e493143415 --- /dev/null +++ b/test/system/avo/events_test.rb @@ -0,0 +1,45 @@ +require "application_system_test_case" + +class Avo::EventsSystemTest < ApplicationSystemTestCase + make_my_diffs_pretty! + + include ActiveJob::TestHelper + + test "user events" do + avo_sign_in_as(create(:admin_github_user, :is_admin)) + + visit avo.root_path + click_link "Events user events" + + assert_selector "div[data-target='title']", text: "Events user events" + + event = create(:events_user_event) + refresh + + assert_content event.tag + + click_link href: avo.resources_events_user_event_path(event) + + assert_content event.tag + assert_content event.cache_key + end + + test "rubygem events" do + avo_sign_in_as(create(:admin_github_user, :is_admin)) + + visit avo.root_path + click_link "Events rubygem events" + + assert_selector "div[data-target='title']", text: "Events rubygem events" + + event = create(:events_rubygem_event) + refresh + + assert_content event.tag + + click_link href: avo.resources_events_rubygem_event_path(event) + + assert_content event.tag + assert_content event.cache_key + end +end diff --git a/test/system/avo/manual_changes_test.rb b/test/system/avo/manual_changes_test.rb new file mode 100644 index 00000000000..9db82895c35 --- /dev/null +++ b/test/system/avo/manual_changes_test.rb @@ -0,0 +1,149 @@ +require "application_system_test_case" + +class Avo::ManualChangesSystemTest < ApplicationSystemTestCase + make_my_diffs_pretty! + + test "auditing changes" do + admin_user = create(:admin_github_user, :is_admin) + avo_sign_in_as admin_user + + Admin::LogTicketPolicy.any_instance.stubs(:avo_create?).returns(true) + Admin::LogTicketPolicy.any_instance.stubs(:avo_update?).returns(true) + Admin::LogTicketPolicy.any_instance.stubs(:avo_destroy?).returns(true) + + visit avo.resources_log_tickets_path + click_on "Create new log ticket" + + fill_in "Key", with: "key" + fill_in "Directory", with: "dir" + fill_in "Processed count", with: "0" + fill_in "Comment", with: "A nice long comment" + click_on "Save" + + page.assert_text "key" + page.assert_text "dir" + page.assert_text "A nice long comment" + page.assert_text "Manual create of LogTicket" + + log_ticket = LogTicket.sole + + page.assert_text log_ticket.id + audit = Audit.sole + + page.assert_text audit.id + assert_equal log_ticket, audit.auditable + assert_equal "LogTicket", audit.auditable_type + assert_equal "Manual create of LogTicket", audit.action + assert_equal( + { + "records" => { + "gid://gemcutter/LogTicket/#{log_ticket.id}" => { + "changes" => log_ticket.attributes.transform_values { [nil, _1.as_json] }, + "unchanged" => {} + } + }, + "fields" => { + "key" => "key", + "directory" => "dir", + "backend" => "s3", + "status" => "pending", + "processed_count" => "0" + }, + "arguments" => {}, + "models" => ["gid://gemcutter/LogTicket/#{log_ticket.id}"] + }, + audit.audited_changes + ) + assert_equal admin_user, audit.admin_github_user + assert_equal "A nice long comment", audit.comment + + find('div[data-field-id="auditable"]').click_on log_ticket.to_param + + page.assert_title(/^#{log_ticket.to_param}/) + + click_on "Edit" + + fill_in "Key", with: "Other Key" + fill_in "Processed count", with: "2" + select "failed", from: "Status" + fill_in "Comment", with: "Another comment" + click_on "Save" + + page.assert_text "Another comment" + + assert_equal 2, Audit.count + + audit = Audit.last + + page.assert_text audit.id + assert_equal log_ticket, audit.auditable + assert_equal "LogTicket", audit.auditable_type + assert_equal "Manual update of LogTicket", audit.action + assert_equal( + { + "records" => { + "gid://gemcutter/LogTicket/#{log_ticket.id}" => { + "changes" => { + "key" => ["key", "Other Key"], + "status" => %w[pending failed], + "updated_at" => audit.audited_changes.dig("records", "gid://gemcutter/LogTicket/#{log_ticket.id}", "changes", "updated_at"), + "processed_count" => [0, 2] + }, + "unchanged" => log_ticket.reload.attributes.except("key", "status", "updated_at", "processed_count") + } + }, + "fields" => { + "key" => "Other Key", + "directory" => "dir", + "backend" => "s3", + "status" => "failed", + "processed_count" => "2" + }, + "arguments" => {}, + "models" => ["gid://gemcutter/LogTicket/#{log_ticket.id}"] + }.as_json, + audit.audited_changes + ) + assert_equal admin_user, audit.admin_github_user + assert_equal "Another comment", audit.comment + + find('div[data-field-id="auditable"]').click_on log_ticket.to_param + + page.assert_title(/^#{log_ticket.to_param}/) + + accept_alert("Are you sure?") do + click_on "Delete" + end + + page.assert_text "Record destroyed" + + assert_raise(ActiveRecord::RecordNotFound) { log_ticket.reload } + + assert_equal 3, Audit.count + audit = Audit.last + visit avo.resources_audit_path(audit) + + page.assert_text "Manual destroy of LogTicket" + + assert_nil audit.auditable + assert_equal log_ticket.id, audit.auditable_id + assert_equal "LogTicket", audit.auditable_type + assert_equal "Manual destroy of LogTicket", audit.action + assert_equal( + { + "records" => { + "gid://gemcutter/LogTicket/#{log_ticket.id}" => { + "changes" => log_ticket.attributes.transform_values { [_1, nil] }, + "unchanged" => {} + } + }, + "fields" => {}, + "arguments" => {}, + "models" => ["gid://gemcutter/LogTicket/#{log_ticket.id}"] + }.as_json, + audit.audited_changes + ) + assert_equal admin_user, audit.admin_github_user + assert_equal "Manual destroy of LogTicket", audit.comment + end +end diff --git a/test/system/avo/oidc_api_key_roles_test.rb b/test/system/avo/oidc_api_key_roles_test.rb new file mode 100644 index 00000000000..23f14311153 --- /dev/null +++ b/test/system/avo/oidc_api_key_roles_test.rb @@ -0,0 +1,58 @@ +require "application_system_test_case" + +class Avo::OIDCApiKeyRolesSystemTest < ApplicationSystemTestCase + make_my_diffs_pretty! + + test "manually changing roles" do + admin_user = create(:admin_github_user, :is_admin) + avo_sign_in_as admin_user + + provider = create(:oidc_provider) + user = create(:user) + + visit avo.resources_oidc_api_key_roles_path + click_on "Create new OIDC api key role" + + select provider.issuer, from: "oidc_api_key_role_oidc_provider_id" + find_field(id: "oidc_api_key_role_user_id").click + send_keys user.display_handle + find("li", text: user.display_handle).click + fill_in "Name", with: "Role" + fill_in "Valid for", with: "PT15M" + fill_in "Comment", with: "A nice long comment" + + click_on "Save" + + page.assert_text "can't be blank" + page.assert_text "Access policy can't be blank" + + assert_field "oidc_api_key_role_oidc_provider_id", with: provider.id + assert_field "oidc_api_key_role_user_id", with: user.display_handle + assert_field "Name", with: "Role" + assert_field "Valid for", with: "PT15M" + + find("div[data-field-id='scopes'] tags").click + send_keys "push_rubygem", :enter + fill_in "Comment", with: "A nice long comment" + + click_on "Add another Statement" + click_on "Add another Condition" + + click_on "Save" + + fill_in "oidc_api_key_role_access_policy_statements_0__principal_oidc", with: provider.issuer + select "String Matches", from: "oidc_api_key_role_access_policy_statements_0__conditions_0__operator" + fill_in "oidc_api_key_role_access_policy_statements_0__conditions_0__claim", with: "sub" + fill_in "oidc_api_key_role_access_policy_statements_0__conditions_0__value", with: "sub-value" + fill_in "Comment", with: "A nice long comment" + + click_on "Save" + + page.assert_text "Role" + + role = provider.api_key_roles.sole + + assert_equal "string_matches", role.access_policy.statements[0].conditions[0].operator + assert_equal OIDC::ApiKeyPermissions.new(valid_for: 15.minutes, gems: [], scopes: ["push_rubygem"]), role.api_key_permissions + end +end diff --git a/test/system/avo/oidc_providers_test.rb b/test/system/avo/oidc_providers_test.rb new file mode 100644 index 00000000000..a3d56d9462a --- /dev/null +++ b/test/system/avo/oidc_providers_test.rb @@ -0,0 +1,152 @@ +require "application_system_test_case" + +class Avo::OIDCProvidersSystemTest < ApplicationSystemTestCase + make_my_diffs_pretty! + + test "refreshing provider" do + admin_user = create(:admin_github_user, :is_admin) + avo_sign_in_as admin_user + + provider = create(:oidc_provider, issuer: "https://token.actions.githubusercontent.com", configuration: nil, jwks: nil) + + visit avo.resources_oidc_provider_path(provider) + click_button "Actions" + click_on "Refresh OIDC Provider" + + stub_request(:get, "https://token.actions.githubusercontent.com/.well-known/openid-configuration").to_return( + status: 200, + body: { + issuer: "https://token.actions.githubusercontent.com", + jwks_uri: "https://token.actions.githubusercontent.com/.well-known/jwks", + subject_types_supported: %w[ + public + pairwise + ], + response_types_supported: [ + "id_token" + ], + claims_supported: %w[ + sub + aud + exp + iat + iss + jti + nbf + ref + repository + repository_id + repository_owner + repository_owner_id + run_id + run_number + run_attempt + actor + actor_id + workflow + workflow_ref + workflow_sha + head_ref + base_ref + event_name + ref_type + environment + environment_node_id + job_workflow_ref + job_workflow_sha + repository_visibility + runner_environment + ], + id_token_signing_alg_values_supported: [ + "RS256" + ], + scopes_supported: [ + "openid" + ] + }.to_json, + headers: { + "content-type" => "application/json; charset=utf-8" + } + ) + stub_request(:get, "https://token.actions.githubusercontent.com/.well-known/jwks").to_return( + status: 200, + body: { + keys: [ + { + n: "4WpHpoBYsVBVfSlfgnRbdPMxP3Eb7rFqE48e4pPM4qH_9EsUZIi21LjOu8UkKn14L4hrRfzfRHG7VQSbxXBU1Qa-xM5yVxdmfQZKBxQnPWaE1v7edjxq1ZYnqHIp90Uvn" \ + "w6798xMCSvI_V3FR8tix5GaoTgkixXlPc-ozifMyEZMmhvuhfDsSxQeTSHGPlWfGkX0id_gYzKPeI69EGtQ9ZN3PLTdoAI8jxlQ-jyDchi9h2ax6hgMLDsMZyiIXnF2UY" \ + "q4j36Cs5RgdC296d0hEOHN0WYZE-xPl7y_A9UHcVjrxeGfVOuTBXqjowofimn4ESnVXNReCsOwZCJlvJzfpQ", + kty: "RSA", + kid: "78167F727DEC5D801DD1C8784C704A1C880EC0E1", + alg: "RS256", + e: "AQAB", + use: "sig", + x5c: [ + "MIIDrDCCApSgAwIBAgIQMPdKi0TFTMqmg1HHo6FfsDANBgkqhkiG9w0BAQsFADA2MTQwMgYDVQQDEyt2c3RzLXZzdHNnaHJ0LWdoLXZzby1vYXV0aC52aXN1YWxzdHVkaW8" \ + "uY29tMB4XDTIyMDEwNTE4NDcyMloXDTI0MDEwNTE4NTcyMlowNjE0MDIGA1UEAxMrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTCCASIwDQ" \ + "YJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOFqR6aAWLFQVX0pX4J0W3TzMT9xG+6xahOPHuKTzOKh//RLFGSIttS4zrvFJCp9eC+Ia0X830Rxu1UEm8VwVNUGvsTOclcXZ" \ + "n0GSgcUJz1mhNb+3nY8atWWJ6hyKfdFL58Ou/fMTAkryP1dxUfLYseRmqE4JIsV5T3PqM4nzMhGTJob7oXw7EsUHk0hxj5VnxpF9Inf4GMyj3iOvRBrUPWTdzy03aACPI8Z" \ + "UPo8g3IYvYdmseoYDCw7DGcoiF5xdlGKuI9+grOUYHQtvendIRDhzdFmGRPsT5e8vwPVB3FY68Xhn1TrkwV6o6MKH4pp+BEp1VzUXgrDsGQiZbyc36UCAwEAAaOBtTCBsjA" \ + "OBgNVHQ8BAf8EBAMCBaAwCQYDVR0TBAIwADAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwNgYDVR0RBC8wLYIrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudm" \ + "lzdWFsc3R1ZGlvLmNvbTAfBgNVHSMEGDAWgBRZBaZCR9ghvStfcWaGwuHGjrfTgzAdBgNVHQ4EFgQUWQWmQkfYIb0rX3FmhsLhxo6304MwDQYJKoZIhvcNAQELBQADggEBA" \ + "GNdfALe6mdxQ67QL8GlW4dfFwvCX87JOeZThZ9uCj1+x1xUnywoR4o5q2DVI/JCvBRPn0BUb3dEVWLECXDHGjblesWZGMdSGYhMzWRQjVNmCYBC1ZM5QvonWCBcGkd72mZx" \ + "0eFHnJCAP/TqEEpRvMHR+OOtSiZWV9zZpF1tf06AjKwT64F9V8PCmSIqPJXcTQXKKfkHZmGUk9AYF875+/FfzF89tCnT53UEh5BldFz0SAls+NhexbW/oOokBNCVqe+T2xX" \ + "izktbFnFAFaomvwjVSvIeu3i/0Ygywl+3s5izMEsZ1T1ydIytv4FZf2JCHgRpmGPWJ5A7TpxuHSiE8Do=" + ], + x5t: "eBZ_cn3sXYAd0ch4THBKHIgOwOE" + }, + { + n: "wgCsNL8S6evSH_AHBsps2ccIHSwLpuEUGS9GYenGmGkSKyWefKsZheKl_84voiUgduuKcKA2aWQezp9338LjtlBmTHjopzAeU-Q3_IvqNf7BfrEAzEyp-ymdhNzPTE7S" \ + "nmr5o_9AeiP1ZDBo35FaULgVUECJ3AzAM36zkURax3VNZRRZx1gb8lPUs9M5Yw6aZpHSOd6q_QzE8CP1OhGrAdoBzZ6ZCElon0kI-IuRLCwKptS7Yroi5-RtEKD2W458" \ + "axNAQ36Yw93N8kInUC1QZDPrKd4QfYiG68ywjBoxp_bjNg5kh4LJmq1mwyGdNQV6F1Ew_jYlmou2Y8wvHQRJPQ", + kty: "RSA", + kid: "52F197C481DE70112C441B4A9B37B53C7FCF0DB5", + alg: "RS256", + e: "AQAB", + use: "sig", + x5c: [ + "MIIDrDCCApSgAwIBAgIQLQnoXJ3HT6uPYvEofvOZ6zANBgkqhkiG9w0BAQsFADA2MTQwMgYDVQQDEyt2c3RzLXZzdHNnaHJ0LWdoLXZzby1vYXV0aC52aXN1YWxzdHVka" \ + "W8uY29tMB4XDTIxMTIwNjE5MDUyMloXDTIzMTIwNjE5MTUyMlowNjE0MDIGA1UEAxMrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTCCAS" \ + "IwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMIArDS/Eunr0h/wBwbKbNnHCB0sC6bhFBkvRmHpxphpEislnnyrGYXipf/OL6IlIHbrinCgNmlkHs6fd9/C47ZQZkx" \ + "46KcwHlPkN/yL6jX+wX6xAMxMqfspnYTcz0xO0p5q+aP/QHoj9WQwaN+RWlC4FVBAidwMwDN+s5FEWsd1TWUUWcdYG/JT1LPTOWMOmmaR0jneqv0MxPAj9ToRqwHaAc2e" \ + "mQhJaJ9JCPiLkSwsCqbUu2K6IufkbRCg9luOfGsTQEN+mMPdzfJCJ1AtUGQz6yneEH2IhuvMsIwaMaf24zYOZIeCyZqtZsMhnTUFehdRMP42JZqLtmPMLx0EST0CAwEAA" \ + "aOBtTCBsjAOBgNVHQ8BAf8EBAMCBaAwCQYDVR0TBAIwADAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwNgYDVR0RBC8wLYIrdnN0cy12c3RzZ2hydC1naC12c2" \ + "8tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTAfBgNVHSMEGDAWgBTTNQQWmG4PZZsdfMeamCH1YcyDZTAdBgNVHQ4EFgQU0zUEFphuD2WbHXzHmpgh9WHMg2UwDQYJKoZIhvc" \ + "NAQELBQADggEBAK/d+HzBSRac7p6CTEolRXcBrBmmeJUDbBy20/XA6/lmKq73dgc/za5VA6Kpfd6EFmG119tl2rVGBMkQwRx8Ksr62JxmCw3DaEhE8ZjRARhzgSiljqXH" \ + "lk8TbNnKswHxWmi4MD2/8QhHJwFj3X35RrdMM4R0dN/ojLlWsY9jXMOAvcSBQPBqttn/BjNzvn93GDrVafyX9CPl8wH40MuWS/gZtXeYIQg5geQkHCyP96M5Sy8ZABOo9" \ + "MSIfPRw1F7dqzVuvliul9ZZGV2LsxmZCBtbsCkBau0amerigZjud8e9SNp0gaJ6wGhLbstCZIdaAzS5mSHVDceQzLrX2oe1h4k=" + ], + x5t: "UvGXxIHecBEsRBtKmze1PH_PDbU" + } + ] + }.to_json, + headers: { + "content-type" => "application/json; charset=utf-8" + } + ) + + fill_in "Comment", with: "A nice long comment" + click_on "Refresh" + + page.assert_text "Action ran successfully!" + page.assert_text provider.to_global_id.uri.to_s + + provider.reload + + audit = provider.audits.sole + + page.assert_text audit.id + assert_equal "OIDC::Provider", audit.auditable_type + assert_equal "Refresh OIDC Provider", audit.action + assert_equal( + "https://token.actions.githubusercontent.com/.well-known/jwks", + audit.audited_changes.dig("records", "gid://gemcutter/OIDC::Provider/#{provider.id}", "changes", "configuration", 1, "jwks_uri") + ) + assert_equal( + "78167F727DEC5D801DD1C8784C704A1C880EC0E1", + audit.audited_changes.dig("records", "gid://gemcutter/OIDC::Provider/#{provider.id}", "changes", "jwks", 1, 0, "kid") + ) + assert_equal admin_user, audit.admin_github_user + assert_equal "A nice long comment", audit.comment + end +end diff --git a/test/system/avo/rubygems_test.rb b/test/system/avo/rubygems_test.rb new file mode 100644 index 00000000000..1b42ed7206c --- /dev/null +++ b/test/system/avo/rubygems_test.rb @@ -0,0 +1,372 @@ +require "application_system_test_case" + +class Avo::RubygemsSystemTest < ApplicationSystemTestCase + make_my_diffs_pretty! + + include ActiveJob::TestHelper + + test "release reserved namespace" do + admin_user = create(:admin_github_user, :is_admin) + avo_sign_in_as admin_user + + rubygem = create(:rubygem, created_at: 40.days.ago) + rubygem_attributes = rubygem.attributes.with_indifferent_access + + refute_predicate rubygem, :pushable? + + visit avo.resources_rubygem_path(rubygem) + + click_button "Actions" + click_on "Release reserved namespace" + + assert_no_changes "Rubygem.find(#{rubygem.id}).attributes" do + click_button "Release namespace" + end + page.assert_text "Must supply a sufficiently detailed comment" + + fill_in "Comment", with: "A nice long comment" + click_button "Release namespace" + + page.assert_text "Action ran successfully!" + page.assert_text rubygem.to_global_id.uri.to_s + + rubygem.reload + + assert_equal 0, rubygem.protected_days + assert_predicate rubygem, :pushable? + + audit = rubygem.audits.sole + + page.assert_text audit.id + assert_equal "Rubygem", audit.auditable_type + assert_equal "Release reserved namespace", audit.action + assert_equal( + { + "records" => { + "gid://gemcutter/Rubygem/#{rubygem.id}" => { + "changes" => { + "updated_at" => [rubygem_attributes[:updated_at].as_json, rubygem.updated_at.as_json] + }, + "unchanged" => rubygem.attributes + .except("updated_at") + .transform_values(&:as_json) + } + }, + "fields" => {}, + "arguments" => {}, + "models" => ["gid://gemcutter/Rubygem/#{rubygem.id}"] + }, + audit.audited_changes + ) + assert_equal admin_user, audit.admin_github_user + assert_equal "A nice long comment", audit.comment + end + + test "Yank a rubygem" do + Minitest::Test.make_my_diffs_pretty! + admin_user = create(:admin_github_user, :is_admin) + avo_sign_in_as admin_user + + security_user = create(:user, email: "security@rubygems.org") + rubygem = create(:rubygem) + version = create(:version, rubygem: rubygem) + GemDownload.increment( + 100_001, + rubygem_id: rubygem.id, + version_id: version.id + ) + + visit avo.resources_rubygem_path(rubygem) + + click_button "Actions" + click_on "Yank Rubygem" + + assert_no_changes "Rubygem.find(#{rubygem.id}).attributes" do + click_button "Yank Rubygem" + end + page.assert_text "Must supply a sufficiently detailed comment" + + fill_in "Comment", with: "A nice long comment" + select(version.number, from: "Version") + + click_button "Yank Rubygem" + + page.assert_text "Action ran successfully!" + page.assert_text rubygem.to_global_id.uri.to_s + + rubygem.reload + version.reload + + assert_not_nil version.yanked_at + assert_not_nil version.yanked_info_checksum + + audit = rubygem.audits.sole + deletion = security_user.deletions.first + + page.assert_text audit.id + assert_equal "Rubygem", audit.auditable_type + assert_equal "Yank Rubygem", audit.action + assert_equal( + { + "fields" => { "version" => version.id.to_s }, + "arguments" => {}, + "models" => ["gid://gemcutter/Rubygem/#{rubygem.id}"], + "records" => + audit.audited_changes["records"].select do |k, _| + k =~ %r{gid://gemcutter/Delayed::Backend::ActiveRecord::Job/\d+} + end.merge( + audit.audited_changes["records"].select do |k, _| + k =~ %r{gid://gemcutter/Version/#{version.id}} + end + ).merge( + audit.audited_changes["records"].select do |k, _| + k =~ %r{gid://gemcutter/Deletion/#{deletion.id}} + end + ).merge( + audit.audited_changes["records"].select do |k, _| + k =~ %r{gid://gemcutter/Rubygem/#{rubygem.id}} + end + ).merge( + audit.audited_changes["records"].select do |k, _| + k =~ %r{gid://gemcutter/Events::RubygemEvent/\d+} + end + ) + }, + audit.audited_changes + ) + assert_equal admin_user, audit.admin_github_user + assert_equal "A nice long comment", audit.comment + end + + test "Yank all version of rubygem" do + admin_user = create(:admin_github_user, :is_admin) + avo_sign_in_as admin_user + + security_user = create(:user, email: "security@rubygems.org") + rubygem = create(:rubygem) + version1 = create(:version, rubygem: rubygem) + version2 = create(:version, rubygem: rubygem) + GemDownload.increment( + 100_001, + rubygem_id: rubygem.id, + version_id: version1.id + ) + + visit avo.resources_rubygem_path(rubygem) + + click_button "Actions" + click_on "Yank Rubygem" + + assert_no_changes "Rubygem.find(#{rubygem.id}).attributes" do + click_button "Yank Rubygem" + end + page.assert_text "Must supply a sufficiently detailed comment" + + fill_in "Comment", with: "A nice long comment" + select("All", from: "Version") + + click_button "Yank Rubygem" + + page.assert_text "Action ran successfully!" + page.assert_text rubygem.to_global_id.uri.to_s + + rubygem.reload + version1.reload + version2.reload + + assert_not_nil version1.yanked_at + assert_not_nil version1.yanked_info_checksum + assert_not_nil version2.yanked_at + assert_not_nil version2.yanked_info_checksum + + audit = rubygem.audits.sole + deletion1 = security_user.deletions.first + deletion2 = security_user.deletions.last + + page.assert_text audit.id + assert_equal "Rubygem", audit.auditable_type + assert_equal "Yank Rubygem", audit.action + + assert_equal( + { + "fields" => { "version" => "All" }, + "arguments" => {}, + "models" => ["gid://gemcutter/Rubygem/#{rubygem.id}"], + "records" => + audit.audited_changes["records"].select do |k, _| + k =~ %r{gid://gemcutter/Delayed::Backend::ActiveRecord::Job/\d+} + end.merge( + audit.audited_changes["records"].select do |k, _| + k =~ %r{gid://gemcutter/Version/#{version1.id}} + end + ).merge( + audit.audited_changes["records"].select do |k, _| + k =~ %r{gid://gemcutter/Version/#{version2.id}} + end + ).merge( + audit.audited_changes["records"].select do |k, _| + k =~ %r{gid://gemcutter/Deletion/#{deletion1.id}} + end + ).merge( + audit.audited_changes["records"].select do |k, _| + k =~ %r{gid://gemcutter/Deletion/#{deletion2.id}} + end + ).merge( + audit.audited_changes["records"].select do |k, _| + k =~ %r{gid://gemcutter/Rubygem/#{rubygem.id}} + end + ).merge( + audit.audited_changes["records"].select do |k, _| + k =~ %r{gid://gemcutter/Events::RubygemEvent/\d+} + end + ) + }, + audit.audited_changes + ) + assert_equal admin_user, audit.admin_github_user + assert_equal "A nice long comment", audit.comment + end + + test "add owner" do + admin_user = create(:admin_github_user, :is_admin) + avo_sign_in_as admin_user + + security_user = create(:user, email: "security@rubygems.org") + rubygem = create(:rubygem) + create(:version, rubygem: rubygem) + + new_owner = create(:user) + + visit avo.resources_rubygem_path(rubygem) + + click_button "Actions" + click_on "Add owner" + + assert_no_changes "Rubygem.find(#{rubygem.id}).then { [_1.attributes, _1.ownerships.map(&:attributes)]}" do + click_button "Add owner" + end + page.assert_text "Must supply a sufficiently detailed comment" + + fill_in "Comment", with: "A nice long comment" + find_field("New owner").click + send_keys new_owner.email + find("li", text: new_owner.handle).click + + click_button "Add owner" + + page.assert_text "Added #{new_owner.handle} to #{rubygem.name}" + page.assert_text rubygem.to_global_id.uri.to_s + + rubygem.reload + + ownership = rubygem.ownerships.where(user: new_owner).sole + + assert_predicate ownership, :confirmed? + assert_equal security_user, ownership.authorizer + + audit = rubygem.audits.sole + event = rubygem.events.where(tag: Events::RubygemEvent::OWNER_ADDED).sole + + page.assert_text audit.id + assert_equal "Rubygem", audit.auditable_type + assert_equal "Add owner", audit.action + + assert_equal( + { + "fields" => { "owner" => { "id" => new_owner.id, "handle" => new_owner.handle } }, + "arguments" => {}, + "models" => ["gid://gemcutter/Rubygem/#{rubygem.id}"], + "records" => + { + ownership.to_gid.to_s => { + "changes" => { + "id" => [nil, ownership.id], + "rubygem_id" => [nil, rubygem.id], + "user_id" => [nil, new_owner.id], + "token" => [nil, ownership.token], + "created_at" => [nil, ownership.created_at.as_json], + "updated_at" => [nil, ownership.updated_at.as_json], + "push_notifier" => [nil, true], + "confirmed_at" => [nil, ownership.confirmed_at.as_json], + "token_expires_at" => [nil, ownership.token_expires_at.as_json], + "owner_notifier" => [nil, true], + "authorizer_id" => [nil, security_user.id], + "ownership_request_notifier" => [nil, true], + "role" => [nil, ownership.role] + }, + "unchanged" => {} + }, + "gid://gemcutter/Events::RubygemEvent/#{event.id}" => { + "changes" => event.attributes.transform_values { [nil, _1.as_json] }, + "unchanged" => {} + } + } + }, + audit.audited_changes + ) + assert_equal admin_user, audit.admin_github_user + assert_equal "A nice long comment", audit.comment + end + + test "upload versions file" do + admin_user = create(:admin_github_user, :is_admin) + avo_sign_in_as admin_user + + visit avo.resources_rubygems_path + + _ = create(:version) + + click_button "Actions" + click_on "Upload Versions File" + fill_in "Comment", with: "A nice long comment" + + assert_enqueued_jobs 1, only: UploadVersionsFileJob do + click_button "Upload" + + page.assert_text "Upload job scheduled" + end + + assert_not_nil Audit.last + end + + test "upload names file" do + admin_user = create(:admin_github_user, :is_admin) + avo_sign_in_as admin_user + + visit avo.resources_rubygems_path + + _ = create(:version) + + click_button "Actions" + click_on "Upload Names File" + fill_in "Comment", with: "A nice long comment" + + assert_enqueued_jobs 1, only: UploadNamesFileJob do + click_button "Upload" + + page.assert_text "Upload job scheduled" + end + + assert_not_nil Audit.last + end + + test "upload info file" do + admin_user = create(:admin_github_user, :is_admin) + avo_sign_in_as admin_user + + version = create(:version) + visit avo.resources_rubygem_path(version.rubygem) + + click_button "Actions" + click_on "Upload Info File" + fill_in "Comment", with: "A nice long comment" + + assert_enqueued_jobs 1, only: UploadInfoFileJob do + click_button "Upload" + + page.assert_text "Upload job scheduled" + end + + assert_not_nil Audit.last + end +end diff --git a/test/system/avo/sendgrid_events_test.rb b/test/system/avo/sendgrid_events_test.rb new file mode 100644 index 00000000000..363742cb49c --- /dev/null +++ b/test/system/avo/sendgrid_events_test.rb @@ -0,0 +1,35 @@ +require "application_system_test_case" + +class Avo::SendgridEventsSystemTest < ApplicationSystemTestCase + include ActiveJob::TestHelper + + test "search for event" do + user = FactoryBot.create(:admin_github_user, :is_admin) + avo_sign_in_as(user) + + visit avo.resources_sendgrid_events_path + + event = FactoryBot.create(:sendgrid_event, email: "abcde@gmail.com") + + visit avo.resources_sendgrid_events_path + + page.assert_text event.email + + click_on "Filters" + fill_in id: "avo_filters_email_filter", with: "nope" + click_on "Filter by email" + + page.assert_no_text event.email + page.assert_text "No record found" + + click_on "Filters" + fill_in id: "avo_filters_email_filter", with: ".+e@gmail.*" + click_on "Filter by email" + + page.assert_text event.email + + visit avo.resources_sendgrid_event_path(event) + + page.assert_text event.sendgrid_id + end +end diff --git a/test/system/avo/users_test.rb b/test/system/avo/users_test.rb new file mode 100644 index 00000000000..0c8d6b6a015 --- /dev/null +++ b/test/system/avo/users_test.rb @@ -0,0 +1,623 @@ +require "application_system_test_case" + +class Avo::UsersSystemTest < ApplicationSystemTestCase + make_my_diffs_pretty! + + include ActiveJob::TestHelper + + test "reset mfa" do + Minitest::Test.make_my_diffs_pretty! + admin_user = create(:admin_github_user, :is_admin) + avo_sign_in_as admin_user + + user = create(:user) + user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + user_attributes = user.attributes.with_indifferent_access + + visit avo.resources_user_path(user) + + click_button "Actions" + click_on "Reset User 2FA" + + assert_no_changes "User.find(#{user.id}).attributes" do + click_button "Reset MFA" + end + page.assert_text "Must supply a sufficiently detailed comment" + + fill_in "Comment", with: "A nice long comment" + click_button "Reset MFA" + + page.assert_text "Action ran successfully!" + page.assert_text user.to_global_id.uri.to_s + + page.assert_no_text user.encrypted_password + page.assert_no_text user_attributes[:encrypted_password] + page.assert_no_text user_attributes[:totp_seed] + page.assert_no_text user_attributes[:mfa_hashed_recovery_codes].first + + user.reload + + assert_equal "disabled", user.mfa_level + assert_not_equal user_attributes[:encrypted_password], user.encrypted_password + assert_nil user.totp_seed + assert_empty user.mfa_hashed_recovery_codes + + audit = user.audits.sole + event = user.events.where(tag: Events::UserEvent::PASSWORD_CHANGED).sole + + page.assert_text audit.id + assert_equal "User", audit.auditable_type + assert_equal "Reset User 2FA", audit.action + assert_equal( + { + "records" => { + "gid://gemcutter/User/#{user.id}" => { + "changes" => { + "mfa_level" => %w[ui_and_api disabled], + "updated_at" => [user_attributes[:updated_at].as_json, user.updated_at.as_json], + "totp_seed" => [user_attributes[:totp_seed], nil], + "mfa_hashed_recovery_codes" => [user_attributes[:mfa_hashed_recovery_codes], []], + "encrypted_password" => [user_attributes[:encrypted_password], user.encrypted_password] + }, + "unchanged" => user.attributes + .except("mfa_level", "updated_at", "totp_seed", "mfa_hashed_recovery_codes", "encrypted_password") + .transform_values(&:as_json) + }, + event.to_gid.as_json => { + "changes" => event.attributes.transform_values { [nil, _1.as_json] }, + "unchanged" => {} + } + }, + "fields" => {}, + "arguments" => {}, + "models" => ["gid://gemcutter/User/#{user.id}"] + }, + audit.audited_changes + ) + assert_equal admin_user, audit.admin_github_user + assert_equal "A nice long comment", audit.comment + end + + test "block user" do + Minitest::Test.make_my_diffs_pretty! + admin_user = create(:admin_github_user, :is_admin) + avo_sign_in_as admin_user + + user = create(:user) + user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + user_attributes = user.attributes.with_indifferent_access + + visit avo.resources_user_path(user) + + click_button "Actions" + click_on "Block User" + + assert_no_changes "User.find(#{user.id}).attributes" do + click_button "Block User" + end + page.assert_text "Must supply a sufficiently detailed comment" + + fill_in "Comment", with: "A nice long comment" + click_button "Block User" + + page.assert_text "Action ran successfully!" + page.assert_text user.to_global_id.uri.to_s + + page.assert_no_text user.encrypted_password + page.assert_no_text user_attributes[:encrypted_password] + page.assert_no_text user_attributes[:totp_seed] + page.assert_no_text user_attributes[:mfa_hashed_recovery_codes].first + + user.reload + + assert_equal "disabled", user.mfa_level + assert_not_equal user_attributes[:encrypted_password], user.encrypted_password + assert_nil user.totp_seed + assert_empty user.mfa_hashed_recovery_codes + + audit = user.audits.sole + email_added_event = user.events.where(tag: Events::UserEvent::EMAIL_ADDED).sole + email_verified_event = user.events.where(tag: Events::UserEvent::EMAIL_VERIFIED).sole + password_changed_event = user.events.where(tag: Events::UserEvent::PASSWORD_CHANGED).sole + + page.assert_text audit.id + assert_equal "User", audit.auditable_type + assert_equal "Block User", audit.action + assert_equal( + { + "records" => { + "gid://gemcutter/User/#{user.id}" => { + "changes" => { + "email" => [user_attributes[:email], user.email], + "updated_at" => [user_attributes[:updated_at].as_json, user.updated_at.as_json], + "confirmation_token" => [user_attributes[:confirmation_token], nil], + "mfa_level" => %w[ui_and_api disabled], + "totp_seed" => [user_attributes[:totp_seed], nil], + "mfa_hashed_recovery_codes" => [user_attributes[:mfa_hashed_recovery_codes], []], + "encrypted_password" => [user_attributes[:encrypted_password], user.encrypted_password], + "api_key" => ["secret123", nil], + "remember_token" => [user_attributes[:remember_token], nil], + "blocked_email" => [nil, user_attributes[:email]] + }, + "unchanged" => user.attributes + .except( + "api_key", + "blocked_email", + "confirmation_token", + "email", + "encrypted_password", + "mfa_level", + "mfa_hashed_recovery_codes", + "totp_seed", + "remember_token", + "updated_at" + ).transform_values(&:as_json) + }, + email_added_event.to_gid.as_json => { + "changes" => email_added_event.attributes.transform_values { [nil, _1.as_json] }, + "unchanged" => {} + }, + email_verified_event.to_gid.as_json => { + "changes" => email_verified_event.attributes.transform_values { [nil, _1.as_json] }, + "unchanged" => {} + }, + password_changed_event.to_gid.as_json => { + "changes" => password_changed_event.attributes.transform_values { [nil, _1.as_json] }, + "unchanged" => {} + } + }, + "fields" => {}, + "arguments" => {}, + "models" => ["gid://gemcutter/User/#{user.id}"] + }, + audit.audited_changes + ) + assert_equal admin_user, audit.admin_github_user + assert_equal "A nice long comment", audit.comment + end + + test "reset api key" do + perform_enqueued_jobs do + admin_user = create(:admin_github_user, :is_admin) + avo_sign_in_as admin_user + + user = create(:user) + user_attributes = user.attributes.with_indifferent_access + + visit avo.resources_user_path(user) + + click_button "Actions" + click_on "Reset Api Key" + + assert_no_changes "User.find(#{user.id}).attributes" do + click_button "Reset Api Key" + end + page.assert_text "Must supply a sufficiently detailed comment" + + fill_in "Comment", with: "A nice long comment" + select("Public Gem", from: "Template") + click_button "Reset Api Key" + + page.assert_text "Action ran successfully!" + + user.reload + + audit = user.audits.sole + event = user.events.where(tag: Events::UserEvent::EMAIL_SENT).sole + + page.assert_text audit.id + assert_equal "User", audit.auditable_type + assert_equal "Reset Api Key", audit.action + assert_equal( + { + "records" => { + "gid://gemcutter/User/#{user.id}" => { + "changes" => { + "api_key" => ["secret123", user.api_key], + "updated_at" => [user_attributes[:updated_at].as_json, user.updated_at.as_json] + }, + "unchanged" => user.attributes + .except( + "api_key", + "updated_at" + ).transform_values(&:as_json) + }, + event.to_gid.as_json => { + "changes" => event.attributes.transform_values { [nil, _1.as_json] }, + "unchanged" => {} + } + }, + "fields" => { "template" => "public_gem_reset_api_key" }, + "arguments" => {}, + "models" => ["gid://gemcutter/User/#{user.id}"] + }, + audit.audited_changes + ) + assert_equal admin_user, audit.admin_github_user + assert_equal "A nice long comment", audit.comment + + mailer = ActionMailer::Base.deliveries.find do |mail| + mail.to.include?(user.email) + end + + assert_equal("RubyGems.org API key was reset", mailer.subject) + end + end + + test "Yank rubygems" do + admin_user = create(:admin_github_user, :is_admin) + avo_sign_in_as admin_user + security_user = create(:user, email: "security@rubygems.org") + + ownership = create(:ownership) + user = ownership.user + rubygem = ownership.rubygem + version = create(:version, rubygem: rubygem) + GemDownload.increment( + 100_001, + rubygem_id: rubygem.id, + version_id: version.id + ) + + user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + version_attributes = version.attributes.with_indifferent_access + + visit avo.resources_user_path(user) + + click_button "Actions" + click_on "Yank all Rubygems" + + assert_no_changes "User.find(#{user.id}).attributes" do + click_button "Yank all Rubygems" + end + page.assert_text "Must supply a sufficiently detailed comment" + + fill_in "Comment", with: "A nice long comment" + click_button "Yank all Rubygems" + + page.assert_text "Action ran successfully!" + page.assert_text user.to_global_id.uri.to_s + + rubygem.reload + version.reload + + audit = user.audits.sole + deletion = security_user.deletions.first + version_yanked_event = rubygem.events.where(tag: Events::RubygemEvent::VERSION_YANKED).sole + + page.assert_text audit.id + assert_equal "User", audit.auditable_type + assert_equal "Yank all Rubygems", audit.action + + rubygem_audit = audit.audited_changes["records"].select do |k, _| + k =~ %r{gid://gemcutter/Rubygem/#{rubygem.id}} + end + rubygem_updated_at_changes = rubygem_audit["gid://gemcutter/Rubygem/#{rubygem.id}"]["changes"]["updated_at"] + + assert_equal( + { + "records" => { + "gid://gemcutter/Deletion/#{deletion.id}" => { + "changes" => deletion.attributes.transform_values { [nil, _1.as_json] }, + "unchanged" => {} + }, + "gid://gemcutter/Version/#{version.id}" => { + "changes" => { + "indexed" => [true, false], + "yanked_at" => [nil, version.yanked_at.as_json], + "updated_at" => [version_attributes[:updated_at].as_json, version.updated_at.as_json], + "yanked_info_checksum" => [nil, version.yanked_info_checksum] + }, + "unchanged" => version.attributes.merge("latest" => true) + .except( + "indexed", + "updated_at", + "yanked_at", + "yanked_info_checksum" + ).transform_values(&:as_json) + }, + "gid://gemcutter/Rubygem/#{rubygem.id}" => { + "changes" => { + "updated_at" => rubygem_updated_at_changes, + "indexed" => [true, false] + }, + "unchanged" => rubygem.attributes + .except( + "updated_at", + "indexed" + ).transform_values(&:as_json) + }, + version_yanked_event.to_gid.to_s => { + "changes" => version_yanked_event.attributes.transform_values { [nil, _1] }.as_json, + "unchanged" => {} + } + }, + "fields" => {}, + "arguments" => {}, + "models" => ["gid://gemcutter/User/#{user.id}"] + }, + audit.audited_changes + ) + + assert_equal admin_user, audit.admin_github_user + assert_equal "A nice long comment", audit.comment + end + + test "yank user" do + admin_user = create(:admin_github_user, :is_admin) + avo_sign_in_as admin_user + security_user = create(:user, email: "security@rubygems.org") + + user = create(:user) + user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + user_attributes = user.attributes.with_indifferent_access + + rubygem = create(:rubygem) + version = create(:version, rubygem: rubygem) + create(:ownership, user: user, rubygem: rubygem) + version_attributes = version.attributes.with_indifferent_access + + visit avo.resources_user_path(user) + + click_button "Actions" + click_on "Yank User" + + assert_no_changes "User.find(#{user.id}).attributes" do + click_button "Yank User" + end + page.assert_text "Must supply a sufficiently detailed comment" + + fill_in "Comment", with: "A nice long comment" + click_button "Yank User" + + page.assert_text "Action ran successfully!" + page.assert_text user.to_global_id.uri.to_s + + page.assert_no_text user.encrypted_password + page.assert_no_text user_attributes[:encrypted_password] + page.assert_no_text user_attributes[:totp_seed] + page.assert_no_text user_attributes[:mfa_hashed_recovery_codes].first + + user.reload + rubygem.reload + version.reload + + audit = user.audits.sole + deletion = security_user.deletions.first + + page.assert_text audit.id + assert_equal "User", audit.auditable_type + assert_equal "Yank User", audit.action + + rubygem_audit = audit.audited_changes["records"].select do |k, _| + k =~ %r{gid://gemcutter/Rubygem/#{rubygem.id}} + end + rubygem_updated_at_changes = rubygem_audit["gid://gemcutter/Rubygem/#{rubygem.id}"]["changes"]["updated_at"] + email_added_event = user.events.where(tag: Events::UserEvent::EMAIL_ADDED).sole + email_verified_event = user.events.where(tag: Events::UserEvent::EMAIL_VERIFIED).sole + password_changed_event = user.events.where(tag: Events::UserEvent::PASSWORD_CHANGED).sole + version_yanked_event = rubygem.events.where(tag: Events::RubygemEvent::VERSION_YANKED).sole + + assert_equal( + { + "records" => { + "gid://gemcutter/Deletion/#{deletion.id}" => { + "changes" => deletion.attributes.transform_values { [nil, _1.as_json] }, + "unchanged" => {} + }, + "gid://gemcutter/Version/#{version.id}" => { + "changes" => { + "indexed" => [true, false], + "yanked_at" => [nil, version.yanked_at.as_json], + "updated_at" => [version_attributes[:updated_at].as_json, version.updated_at.as_json], + "yanked_info_checksum" => [nil, version.yanked_info_checksum] + }, + "unchanged" => version.attributes.merge("latest" => true) + .except( + "indexed", + "updated_at", + "yanked_at", + "yanked_info_checksum" + ).transform_values(&:as_json) + }, + "gid://gemcutter/Rubygem/#{rubygem.id}" => { + "changes" => { + "updated_at" => rubygem_updated_at_changes, + "indexed" => [true, false] + }, + "unchanged" => rubygem.attributes + .except( + "updated_at", + "indexed" + ).transform_values(&:as_json) + }, + "gid://gemcutter/User/#{user.id}" => { + "changes" => { + "email" => [user_attributes[:email], user.email], + "updated_at" => [user_attributes[:updated_at].as_json, user.updated_at.as_json], + "confirmation_token" => [user_attributes[:confirmation_token], nil], + "mfa_level" => %w[ui_and_api disabled], + "totp_seed" => [user_attributes[:totp_seed], nil], + "mfa_hashed_recovery_codes" => [user_attributes[:mfa_hashed_recovery_codes], []], + "encrypted_password" => [user_attributes[:encrypted_password], user.encrypted_password], + "api_key" => ["secret123", nil], + "remember_token" => [user_attributes[:remember_token], nil], + "blocked_email" => [nil, user_attributes[:email]] + }, + "unchanged" => user.attributes + .except( + "api_key", + "blocked_email", + "confirmation_token", + "email", + "encrypted_password", + "mfa_level", + "mfa_hashed_recovery_codes", + "totp_seed", + "remember_token", + "updated_at" + ).transform_values(&:as_json) + }, + email_added_event.to_gid.as_json => { + "changes" => email_added_event.attributes.transform_values { [nil, _1.as_json] }, + "unchanged" => {} + }, + email_verified_event.to_gid.as_json => { + "changes" => email_verified_event.attributes.transform_values { [nil, _1.as_json] }, + "unchanged" => {} + }, + password_changed_event.to_gid.as_json => { + "changes" => password_changed_event.attributes.transform_values { [nil, _1.as_json] }, + "unchanged" => {} + }, + version_yanked_event.to_gid.as_json => { + "changes" => version_yanked_event.attributes.transform_values { [nil, _1.as_json] }, + "unchanged" => {} + } + }, + "fields" => {}, + "arguments" => {}, + "models" => ["gid://gemcutter/User/#{user.id}"] + }, + audit.audited_changes + ) + + assert_equal admin_user, audit.admin_github_user + assert_equal "A nice long comment", audit.comment + end + + test "change user email" do + perform_enqueued_jobs do + admin_user = create(:admin_github_user, :is_admin) + avo_sign_in_as admin_user + + user = create(:user) + user_attributes = user.attributes.with_indifferent_access + + visit avo.resources_user_path(user) + + click_button "Actions" + click_on "Change User Email" + + assert_no_changes "User.find(#{user.id}).attributes" do + click_button "Change User Email" + end + page.assert_text "Must supply a sufficiently detailed comment" + + fill_in "Comment", with: "A nice long comment" + fill_in "Email", with: "gem-maintainer-001@example.com" + click_button "Change User Email" + + page.assert_text "Action ran successfully!" + + user.reload + + audit = user.audits.sole + email_added_event = user.events.where(tag: Events::UserEvent::EMAIL_ADDED).sole + email_sent_event = user.events.where(tag: Events::UserEvent::EMAIL_SENT).sole + + page.assert_text audit.id + assert_equal "User", audit.auditable_type + assert_equal "Change User Email", audit.action + assert_equal( + { + "records" => { + "gid://gemcutter/User/#{user.id}" => { + "changes" => { + "updated_at" => [user_attributes[:updated_at].as_json, user.updated_at.as_json], + "email" => [user_attributes[:email], user.email], + "email_confirmed" => [true, false], + "confirmation_token" => [user_attributes[:confirmation_token], user.confirmation_token], + "token_expires_at" => [user_attributes[:token_expires_at].as_json, user.token_expires_at.as_json] + }, + "unchanged" => user.attributes + .except( + "email", + "token_expires_at", + "email_confirmed", + "confirmation_token", + "updated_at" + ).transform_values(&:as_json) + }, + email_added_event.to_gid.as_json => { + "changes" => email_added_event.attributes.transform_values { [nil, _1.as_json] }, + "unchanged" => {} + }, + email_sent_event.to_gid.as_json => { + "changes" => email_sent_event.attributes.transform_values { [nil, _1.as_json] }, + "unchanged" => {} + } + }, + "fields" => { "from_email" => "gem-maintainer-001@example.com" }, + "arguments" => {}, + "models" => ["gid://gemcutter/User/#{user.id}"] + }, + audit.audited_changes + ) + assert_equal admin_user, audit.admin_github_user + assert_equal "A nice long comment", audit.comment + + mailer = ActionMailer::Base.deliveries.find do |mail| + mail.to.include?(user.email) + end + + assert_equal("Please confirm your email address with RubyGems.org", mailer.subject) + end + end + + test "create user" do + admin_user = create(:admin_github_user, :is_admin) + avo_sign_in_as admin_user + + visit avo.resources_users_path + + click_button "Actions" + click_on "Create User" + + assert_no_changes "User.count" do + click_button "Create User" + end + page.assert_text "Must supply a sufficiently detailed comment" + + fill_in "Comment", with: "A nice long comment" + fill_in "Email", with: "gem-user-001@example.com" + click_button "Create User" + + page.assert_text "Action ran successfully!" + perform_enqueued_jobs + + user = User.sole + audit = user.audits.sole + event = user.events.where(tag: Events::UserEvent::CREATED).sole + + page.assert_text audit.id + assert_equal "User", audit.auditable_type + assert_equal "Create User", audit.action + assert_equal( + { + "records" => { + "gid://gemcutter/User/#{user.id}" => { + "changes" => user.attributes.transform_values { [nil, _1.as_json] }, + "unchanged" => {} + }, + event.to_gid.as_json => { + "changes" => event.attributes.transform_values { [nil, _1.as_json] }, + "unchanged" => {} + } + }, + "fields" => { "email" => "gem-user-001@example.com" }, + "arguments" => {}, + "models" => nil + }, + audit.audited_changes + ) + assert_equal admin_user, audit.admin_github_user + assert_equal "A nice long comment", audit.comment + + mailers = ActionMailer::Base.deliveries.select do |mail| + mail.to.include?(user.email) + end + + assert_equal(["Change your password"], mailers.map(&:subject)) + end +end diff --git a/test/system/avo/versions_test.rb b/test/system/avo/versions_test.rb new file mode 100644 index 00000000000..1e7842843a9 --- /dev/null +++ b/test/system/avo/versions_test.rb @@ -0,0 +1,152 @@ +require "application_system_test_case" + +class Avo::VersionsSystemTest < ApplicationSystemTestCase + make_my_diffs_pretty! + + include ActiveJob::TestHelper + + test "restore a rubygem version" do + admin_user = create(:admin_github_user, :is_admin) + avo_sign_in_as admin_user + + rubygem = create(:rubygem) + version = create(:version, rubygem: rubygem) + deletion = create(:deletion, version: version) + version_attributes = version.attributes.with_indifferent_access + rubygem_attributes = rubygem.attributes.with_indifferent_access + deletion_attributes = deletion.attributes.with_indifferent_access + + visit avo.resources_version_path(version) + + click_button "Actions" + click_on "Restore version" + + assert_no_changes "Version.find(#{version.id}).attributes" do + click_button "Restore version" + end + page.assert_text "Must supply a sufficiently detailed comment" + + fill_in "Comment", with: "A nice long comment" + + click_button "Restore version" + + page.assert_text "Action ran successfully!" + page.assert_text version.to_global_id.uri.to_s + + rubygem.reload + version.reload + + assert deletion.version.indexed + assert_nil version.yanked_at + assert_nil version.yanked_info_checksum + + audit = version.audits.sole + + page.assert_text audit.id + assert_equal "Version", audit.auditable_type + assert_equal "Restore version", audit.action + assert_equal admin_user, audit.admin_github_user + assert_equal "A nice long comment", audit.comment + rubygem_audit = audit.audited_changes["records"].select do |k, _| + k =~ %r{gid://gemcutter/Rubygem/#{rubygem.id}} + end + rubygem_updated_at_changes = rubygem_audit["gid://gemcutter/Rubygem/#{rubygem.id}"]["changes"]["updated_at"] + version_unyank_event = rubygem.events.where(tag: Events::RubygemEvent::VERSION_UNYANKED).sole + + assert_equal( + { + "records" => { + "gid://gemcutter/Version/#{version.id}" => + { + "changes" => + { + "indexed" => [false, true], + "yanked_at" => [version_attributes[:yanked_at].as_json, nil], + "yanked_info_checksum" => [version_attributes[:yanked_info_checksum], nil], + "updated_at" => [version_attributes[:updated_at].as_json, version.updated_at.as_json] + }, + "unchanged" => version_attributes + .except("updated_at", "yanked_info_checksum", "yanked_at", "indexed") + .merge("position" => 0, "latest" => false) + .transform_values(&:as_json) + }, + "gid://gemcutter/Rubygem/#{rubygem.id}" => + { + "changes" => + { + "updated_at" => rubygem_updated_at_changes, + "indexed" => [false, true] + }, + "unchanged" => rubygem_attributes + .except("updated_at", "indexed") + .transform_values(&:as_json) + }, + "gid://gemcutter/Deletion/#{deletion.id}" => + { + "changes" => + { + "id" => [deletion.id, nil], + "user_id" => [deletion_attributes[:user_id], nil], + "rubygem" => [rubygem.name, nil], + "number" => [version_attributes[:number], nil], + "platform" => ["ruby", nil], + "created_at" => [deletion.created_at.as_json, nil], + "updated_at" => [deletion.updated_at.as_json, nil], + "version_id" => [version.id, nil] + }, + "unchanged" => {} + }, + version_unyank_event.to_gid.to_s => { + "changes" => version_unyank_event.attributes.transform_values { [nil, _1] }.as_json, + "unchanged" => {} + } + }, + "fields" => {}, + "arguments" => {}, + "models" => ["gid://gemcutter/Version/#{version.id}"] + }, + audit.audited_changes + ) + + assert_event Events::RubygemEvent::VERSION_UNYANKED, { + number: version.number, + platform: version.platform, + version_gid: version.to_gid.to_s + }, version_unyank_event + end + + test "run afer version write job" do + admin_user = create(:admin_github_user, :is_admin) + avo_sign_in_as admin_user + + rubygem = create(:rubygem, owners: [create(:user)]) + version = create(:version, rubygem: rubygem) + + visit avo.resources_version_path(version) + + click_button "Actions" + + click_on "Run version post-write job" + + fill_in "Comment", with: "A nice long comment" + + click_button "Run Job" + + page.assert_text "Action ran successfully!" + page.assert_text version.to_global_id.uri.to_s + + perform_enqueued_jobs + + assert_equal 1, ActionMailer::Base.deliveries.size + + rubygem.reload + version.reload + + audit = version.audits.sole + + assert_equal( + ["gid://gemcutter/Version/#{version.id}"], + audit.audited_changes["models"] + ) + end +end diff --git a/test/system/avo/web_hooks_test.rb b/test/system/avo/web_hooks_test.rb new file mode 100644 index 00000000000..bbc2c22ca7b --- /dev/null +++ b/test/system/avo/web_hooks_test.rb @@ -0,0 +1,70 @@ +require "application_system_test_case" + +class Avo::WebHooksSystemTest < ApplicationSystemTestCase + include ActiveJob::TestHelper + + test "delete webhook" do + Minitest::Test.make_my_diffs_pretty! + admin_user = create(:admin_github_user, :is_admin) + avo_sign_in_as admin_user + + web_hook = create(:global_web_hook) + + visit avo.resources_web_hook_path(web_hook) + + click_button "Actions" + click_on "Delete Webhook" + + assert_no_changes "WebHook.find(#{web_hook.id}).attributes" do + click_button "Delete Webhook" + end + page.assert_text "Must supply a sufficiently detailed comment" + + fill_in "Comment", with: "A nice long comment" + click_button "Delete Webhook" + + page.assert_text "Action ran successfully!" + page.assert_text web_hook.to_global_id.uri.to_s + + audit = Audit.sole + + page.assert_text audit.id + assert_equal "WebHook", audit.auditable_type + assert_equal "Delete Webhook", audit.action + assert_equal( + { + "records" => { + "gid://gemcutter/WebHook/#{web_hook.id}" => { + "changes" => { + "id" => [web_hook.id, nil], + "user_id" => [web_hook.user_id, nil], + "url" => [web_hook.url, nil], + "failure_count" => [0, nil], + "created_at" => [web_hook.created_at.as_json, nil], + "updated_at" => [web_hook.updated_at.as_json, nil], + "successes_since_last_failure" => [0, nil], + "failures_since_last_success" => [0, nil] + }, + "unchanged" => { + "rubygem_id" => nil, + "disabled_reason" => nil, + "disabled_at" => nil, + "last_success" => nil, + "last_failure" => nil + } + } + }, + "fields" => {}, + "arguments" => {}, + "models" => ["gid://gemcutter/WebHook/#{web_hook.id}"] + }, + audit.audited_changes + ) + assert_equal admin_user, audit.admin_github_user + assert_equal "A nice long comment", audit.comment + + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob + + assert_equal I18n.t("mailer.web_hook_deleted.subject"), last_email.subject + end +end diff --git a/test/system/gem_server_conformance_test.rb b/test/system/gem_server_conformance_test.rb new file mode 100644 index 00000000000..ec64139de96 --- /dev/null +++ b/test/system/gem_server_conformance_test.rb @@ -0,0 +1,68 @@ +require "application_system_test_case" + +require_relative "../../lib/gemcutter/middleware/hostess" + +class GemServerConformanceTest < ApplicationSystemTestCase + include ActionDispatch::Assertions::RoutingAssertions + include ActiveJob::TestHelper + + setup do + @tmp_versions_file = Tempfile.new("tmp_versions_file") + tmp_path = @tmp_versions_file.path + + Rails.application.config.rubygems.stubs(:[]).with("versions_file_location").returns(tmp_path) + Rails.application.config.rubygems.stubs(:[]).with("s3_compact_index_bucket").returns("s3_compact_index_bucket") + Rails.application.config.rubygems.stubs(:[]).with("s3_contents_bucket").returns("s3_contents_bucket") + + test = self + Rails.application.routes.disable_clear_and_finalize = true + Rails.application.routes.draw do + post "/set_time", to: lambda { |env| + test.travel_to Time.iso8601(Rack::Request.new(env).body.read) + [200, {}, ["OK"]] + } + post "/rebuild_versions_list", to: lambda { |_env| + Rake::Task["compact_index:update_versions_file"].execute + Rake::Task["compact_index:update_versions_file"].reenable + [200, {}, ["OK"]] + } + hostess = Gemcutter::Middleware::Hostess.new(nil) + to = lambda { |env| + hostess.call(env) + .tap do |response| + response[1].delete("x-cascade") + end + } + match "/quick/Marshal.4.8/:name", to:, via: :get, constraints: { name: /[A-Za-z0-9._-]+/ } + match "/gems/:name", to:, via: :get, constraints: { name: Patterns::ROUTE_PATTERN } + match "/specs.4.8.gz", to:, via: :get + match "/prerelease_specs.4.8.gz", to:, via: :get + match "/latest_specs.4.8.gz", to:, via: :get + end + + Indexer.perform_now + @subscriber = ActiveSupport::Notifications.subscribe("process_action.action_controller") do + perform_enqueued_jobs only: [Indexer] + end + end + + teardown do + ActiveSupport::Notifications.unsubscribe(@subscriber) + Rails.application.reload_routes! + end + + test "is a conformant gem server" do + create(:api_key, scopes: %w[push_rubygem yank_rubygem]) + + output, status = Open3.capture2e( + { + "UPSTREAM" => "http://#{Capybara.current_session.config.server_host}:#{Capybara.current_session.config.server_port}", + "GEM_HOST_API_KEY" => "12345" + }, + "gem_server_conformance", + "--fail-fast", "--tag=~content_type_header", "--tag=~content_length_header" + ) + + assert_predicate status, :success?, output + end +end diff --git a/test/system/maintenance_tasks_test.rb b/test/system/maintenance_tasks_test.rb new file mode 100644 index 00000000000..207ea25cb7c --- /dev/null +++ b/test/system/maintenance_tasks_test.rb @@ -0,0 +1,34 @@ +require "application_system_test_case" + +class MaintenanceTasksTest < ApplicationSystemTestCase + make_my_diffs_pretty! + + include ActiveJob::TestHelper + + test "auditing create run" do + admin_user = create(:admin_github_user, :is_admin) + avo_sign_in_as admin_user + + visit admin_maintenance_tasks.root_path + click_on "Maintenance::UserTotpSeedEmptyToNilTask" + + assert_difference "Audit.count", 1 do + assert_enqueued_jobs 1, only: MaintenanceTasks::TaskJob do + click_on "Run" + + page.assert_text "Enqueued" + end + end + + audit = Audit.sole + + assert_equal admin_user, audit.admin_github_user + assert_equal "Manual create of Maintenance::UserTotpSeedEmptyToNilTask", audit.action + assert_equal MaintenanceTasks::Run.sole, audit.auditable + + visit avo.resources_audit_path(audit) + + page.assert_text "Manual create of Maintenance::UserTotpSeedEmptyToNilTask" + page.assert_text MaintenanceTasks::Run.sole.job_id + end +end diff --git a/test/system/multifactor_auths_test.rb b/test/system/multifactor_auths_test.rb new file mode 100644 index 00000000000..69a06758f53 --- /dev/null +++ b/test/system/multifactor_auths_test.rb @@ -0,0 +1,239 @@ +require "application_system_test_case" + +class MultifactorAuthsTest < ApplicationSystemTestCase + setup do + @user = create(:user, email: "testuser@example.com", password: PasswordHelpers::SECURE_TEST_PASSWORD, handle: "testuser") + @seed = ROTP::Base32.random_base32 + @totp = ROTP::TOTP.new(@seed) + end + + teardown do + @user.disable_totp! + @authenticator&.remove! + Capybara.reset_sessions! + Capybara.use_default_driver + end + + context "cache-control" do + should "setup mfa does not cache OTP setup" do + sign_in + + register_otp_device + + assert page.has_content? "Recovery codes" + + go_back + + assert page.has_content? "has already been enabled" + refute page.has_content? "Register a new device" + refute page.has_content? @otp_key + end + + should "setup mfa does not cache recovery codes" do + sign_in + + register_otp_device + + assert page.has_content? "Recovery codes" + click_link "Copy to clipboard" + check "ack" + click_button "Continue" + + go_back + + refute page.has_content? "Recovery codes" + end + end + + context "strong mfa required" do + setup do + @rubygem = create(:rubygem) + create(:ownership, rubygem: @rubygem, user: @user) + GemDownload.increment( + Rubygem::MFA_REQUIRED_THRESHOLD + 1, + rubygem_id: @rubygem.id + ) + end + + context "with mfa disabled" do + should "user with mfa disabled gets redirected back to adoptions after setting up mfa" do + redirect_test_mfa_disabled(adoptions_profile_path) + end + + should "user with mfa disabled gets redirected back to dashboard pages after setting up mfa" do + redirect_test_mfa_disabled(dashboard_path) + end + + should "user with mfa disabled gets redirected back to delete profile pages after setting up mfa" do + redirect_test_mfa_disabled(delete_profile_path) + end + + should "user with mfa disabled gets redirected back to edit profile pages after setting up mfa" do + redirect_test_mfa_disabled(edit_profile_path) + end + + should "user with mfa disabled gets redirected back to new api keys pages after setting up mfa" do + redirect_test_mfa_disabled(new_profile_api_key_path) { verify_password } + end + + should "user with mfa disabled gets redirected back to notifier pages after setting up mfa" do + redirect_test_mfa_disabled(notifier_path) + end + + should "user with mfa disabled gets redirected back to profile api keys pages after setting up mfa" do + create(:api_key, scopes: %i[push_rubygem], owner: @user, ownership: @ownership) + redirect_test_mfa_disabled(profile_api_keys_path) { verify_password } + end + + should "user with mfa disabled gets redirected back to verify session pages after setting up mfa" do + redirect_test_mfa_disabled(verify_session_path) + end + end + + context "with weak level mfa" do + should "user gets redirected back to adoptions after setting up mfa" do + redirect_test_mfa_weak_level(adoptions_profile_path) + end + + should "user gets redirected back to dashboard pages after setting up mfa" do + redirect_test_mfa_weak_level(dashboard_path) + end + + should "user gets redirected back to delete profile pages after setting up mfa" do + redirect_test_mfa_weak_level(delete_profile_path) + end + + should "user gets redirected back to edit profile pages after setting up mfa" do + redirect_test_mfa_weak_level(edit_profile_path) + end + + should "user gets redirected back to new api keys pages after setting up mfa" do + redirect_test_mfa_weak_level(new_profile_api_key_path) { verify_password } + end + + should "user gets redirected back to notifier pages after setting up mfa" do + redirect_test_mfa_weak_level(notifier_path) + end + + should "user gets redirected back to profile api keys pages after setting up mfa" do + create(:api_key, scopes: %i[push_rubygem], owner: @user, ownership: @ownership) + redirect_test_mfa_weak_level(profile_api_keys_path) { verify_password } + end + + should "user gets redirected back to verify session pages after setting up mfa" do + redirect_test_mfa_weak_level(verify_session_path) + end + end + end + + context "updating mfa level" do + should "user with otp can change mfa level" do + sign_in + @user.enable_totp!(@seed, :ui_and_gem_signin) + + visit edit_settings_path + + assert page.has_content?("UI and gem signin"), "UI and gem signin was not the default level" + + change_auth_level "UI and API (Recommended)" + fill_in "otp", with: @totp.now + click_button "Authenticate" + + assert_current_path(edit_settings_path) + assert page.has_content?("UI and API (Recommended)"), "MFA level was not updated" + end + + should "user with webauthn can change mfa level" do + fullscreen_headless_chrome_driver + + sign_in + visit edit_settings_path + + @authenticator = create_webauthn_credential_while_signed_in + + assert page.has_content?("UI and gem signin"), "UI and gem signin was not the default level" + + change_auth_level "UI and API (Recommended)" + + assert page.has_content? "Multi-factor authentication" + assert page.has_content? "Security Device" + click_on "Authenticate with security device" + + assert_current_path(edit_settings_path) + assert page.has_content?("UI and API (Recommended)"), "MFA level was not updated" + end + end + + def redirect_test_mfa_disabled(path) + sign_in + visit path + + assert(page.has_content?("you are required to set up multi-factor authentication")) + assert_current_path(edit_settings_path) + + register_otp_device + + assert page.has_content? "Recovery codes" + click_link "Copy to clipboard" + check "ack" + click_button "Continue" + yield if block_given? + + assert_equal path, current_path, "was not redirected back to original destination: #{path}" + end + + def redirect_test_mfa_weak_level(path) + sign_in + @user.enable_totp!(@seed, :ui_only) + visit path + + assert page.has_content? "Edit settings" + + change_auth_level "UI and gem signin" + fill_in "otp", with: @totp.now + + click_button "Authenticate" + + yield if block_given? + + assert_equal path, current_path, "was not redirected back to original destination: #{path}" + end + + def sign_in + visit sign_in_path + fill_in "Email or Username", with: @user.reload.email + fill_in "Password", with: @user.password + click_button "Sign in" + end + + def otp_key + key_regex = /( (\w{4})){8}/ + page.find_by_id("otp-key").text.match(key_regex)[0].delete("\s") + end + + def register_otp_device + visit edit_settings_path + click_button "Register a new device" + @otp_key = otp_key + totp = ROTP::TOTP.new(@otp_key) + fill_in "otp", with: totp.now + click_button "Enable" + @otp_key + end + + def verify_password + return unless page.has_css? "#verify_password_password" + + fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD + click_button "Confirm" + end + + def change_auth_level(type) + page.select type + click_button "Update" + end + + def go_back + page.evaluate_script("window.history.back()") + end +end diff --git a/test/system/oidc_test.rb b/test/system/oidc_test.rb new file mode 100644 index 00000000000..a6e19618a8a --- /dev/null +++ b/test/system/oidc_test.rb @@ -0,0 +1,355 @@ +require "application_system_test_case" + +class OIDCTest < ApplicationSystemTestCase + setup do + @user = create(:user, password: PasswordHelpers::SECURE_TEST_PASSWORD) + @provider = create(:oidc_provider, issuer: "https://token.actions.githubusercontent.com") + @api_key_role = create(:oidc_api_key_role, user: @user, provider: @provider) + @id_token = create(:oidc_id_token, user: @user, api_key_role: @api_key_role) + end + + def sign_in + visit sign_in_path + fill_in "Email or Username", with: @user.reload.email + fill_in "Password", with: @user.password + click_button "Sign in" + end + + def verify_session # rubocop:disable Minitest/TestMethodName + page.assert_title(/^Confirm Password/) + fill_in "Password", with: @user.password + click_button "Confirm" + end + + test "viewing providers" do + sign_in + visit profile_oidc_providers_path + verify_session + + page.assert_selector "h1", text: "OIDC Providers" + page.assert_text(/displaying 1 provider/i) + page.click_link "https://token.actions.githubusercontent.com" + + page.assert_selector "h1", text: "OIDC Provider" + page.assert_text "https://token.actions.githubusercontent.com" + page.assert_text "https://token.actions.githubusercontent.com/.well-known/jwks" + page.assert_text(/Displaying 1 api key role/i) + assert_link @id_token.api_key_role.name, href: profile_oidc_api_key_role_path(@id_token.api_key_role.token) + end + + test "viewing api key roles" do + sign_in + visit profile_oidc_api_key_roles_path + verify_session + + page.assert_selector "h1", text: "OIDC API Key Roles" + page.assert_text(/displaying 1 api key role/i) + page.click_link @id_token.api_key_role.name + + page.assert_selector "h1", text: "API Key Role #{@id_token.api_key_role.name}" + page.assert_text @id_token.api_key_role.token + page.assert_text "Scopes\npush_rubygem" + page.assert_text "Gems\nAll Gems" + page.assert_text "Valid for\n30 minutes" + page.assert_text "Effect\nallow" + page.assert_text "Principal\nhttps://token.actions.githubusercontent.com" + page.assert_text "Conditions\nsub string_equals repo:segiddins/oidc-test:ref:refs/heads/main" + page.assert_text(/Displaying 1 id token/i) + assert_link "View provider https://token.actions.githubusercontent.com", href: profile_oidc_provider_path(@provider) + assert_link @id_token.jti, href: profile_oidc_id_token_path(@id_token) + end + + test "viewing id tokens" do + sign_in + visit profile_oidc_id_tokens_path + verify_session + + page.assert_selector "h1", text: "OIDC ID Tokens" + page.assert_text(/displaying 1 id token/i) + page.click_link @id_token.jti + + page.assert_selector "h1", text: "OIDC ID Token" + page.assert_text "CREATED AT\n#{@id_token.created_at.to_fs(:long)}" + page.assert_text "EXPIRES AT\n#{@id_token.api_key.expires_at.to_fs(:long)}" + page.assert_text "JWT ID\n#{@id_token.jti}" + assert_link @api_key_role.name, href: profile_oidc_api_key_role_path(@api_key_role.token) + assert_link "https://token.actions.githubusercontent.com", href: profile_oidc_provider_path(@provider) + page.assert_text "jti\n#{@id_token.jti}" + page.assert_text "claim1\nvalue1" + page.assert_text "claim2\nvalue2" + page.assert_text "typ\nJWT" + end + + test "creating an api key role" do + rubygem = create(:rubygem, owners: [@user]) + create(:version, rubygem: rubygem, metadata: { "source_code_uri" => "https://github.com/example/repo" }) + + sign_in + visit rubygem_path(rubygem.slug) + click_link "OIDC: Create" + verify_session + + page.assert_selector "h1", text: "New OIDC API Key Role" + assert_field "Name", with: "Push #{rubygem.name}" + assert_select "OIDC provider", options: ["https://token.actions.githubusercontent.com"], selected: "https://token.actions.githubusercontent.com" + assert_checked_field "Push rubygem" + assert_field "Valid for", with: "PT30M" + assert_select "Gem Scope", options: ["All Gems", rubygem.name], selected: rubygem.name + + assert_select "Effect", options: %w[allow deny], selected: "allow", + id: "oidc_api_key_role_access_policy_statements_attributes_0_effect" + assert_field "Claim", with: "aud", + id: "oidc_api_key_role_access_policy_statements_attributes_0_conditions_attributes_0_claim" + assert_select "Operator", options: ["String Equals", "String Matches"], selected: "String Equals", + id: "oidc_api_key_role_access_policy_statements_attributes_0_conditions_attributes_0_operator" + assert_field "Value", with: Gemcutter::HOST, + id: "oidc_api_key_role_access_policy_statements_attributes_0_conditions_attributes_0_value" + assert_field "Claim", with: "repository", + id: "oidc_api_key_role_access_policy_statements_attributes_0_conditions_attributes_1_claim" + assert_select "Operator", options: ["String Equals", "String Matches"], selected: "String Equals", + id: "oidc_api_key_role_access_policy_statements_attributes_0_conditions_attributes_1_operator" + assert_field "Value", with: "example/repo", + id: "oidc_api_key_role_access_policy_statements_attributes_0_conditions_attributes_1_value" + + page.scroll_to page.find(id: "oidc_api_key_role_access_policy_statements_attributes_0_conditions_attributes_1_claim") + + click_button "Create Api key role" + + page.assert_selector "h1", text: "API Key Role Push #{rubygem.name}" + + role = OIDC::ApiKeyRole.where(name: "Push #{rubygem.name}", user: @user, provider: @provider).sole + + token = role.token + expected = { + "name" => "Push #{rubygem.name}", + "token" => token, + "api_key_permissions" => { + "scopes" => ["push_rubygem"], + "valid_for" => 1800, + "gems" => [rubygem.name] + }, + "access_policy" => { + "statements" => [ + { + "effect" => "allow", + "principal" => { "oidc" => "https://token.actions.githubusercontent.com" }, + "conditions" => [ + { "operator" => "string_equals", "claim" => "aud", "value" => "localhost" }, + { "operator" => "string_equals", "claim" => "repository", "value" => "example/repo" } + ] + } + ] + } + } + + assert_equal(expected, role.as_json.slice(*expected.keys)) + + click_button "Edit API Key Role" + page.scroll_to :bottom + click_button "Update Api key role" + + page.assert_selector "h1", text: "API Key Role Push #{rubygem.name}" + assert_equal(expected, role.reload.as_json.slice(*expected.keys)) + + click_button "Edit API Key Role" + + click_button "Add statement" + + statements = page.find_all(id: /oidc_api_key_role_access_policy_statements_attributes_\d+_wrapper/) + + assert_equal 2, statements.size + + new_statement = statements.last + new_statement.select "deny", from: "Effect" + new_statement.fill_in "Claim", with: "sub" + new_statement.select "String Matches", from: "Operator" + new_statement.fill_in "Value", with: "repo:example/repo:ref:refs/tags/.*" + new_statement.click_button "Add condition" + new_condition = new_statement.find_all(id: /oidc_api_key_role_access_policy_statements_attributes_\d+_conditions_attributes_\d+_wrapper/).last + new_condition.fill_in "Claim", with: "fudge" + new_condition.select "String Equals", from: "Operator" + + statements.first.find_all("button", text: "Remove condition").last.click + + page.assert_selector("button.form__remove_nested_button", text: "Remove condition", count: 3) + + click_button "Update Api key role" + + page.assert_text "Access policy statements[1] conditions[1] claim unknown for the provider" + assert_equal(expected, role.reload.as_json.slice(*expected.keys)) + + page.find_field("Claim", with: "fudge").fill_in with: "event_name" + + page.find_field("Name").fill_in with: "Push gems" + page.select "All Gems", from: "Gem Scope" + page.unselect rubygem.name, from: "Gem Scope" + page.check "Yank rubygem" + + click_button "Update Api key role" + + page.assert_selector "h1", text: "API Key Role Push gems" + assert_equal(expected.merge( + "name" => "Push gems", + "api_key_permissions" => { + "scopes" => %w[push_rubygem yank_rubygem], "valid_for" => 1800, "gems" => nil + }, + "access_policy" => { + "statements" => [ + { + "effect" => "allow", + "principal" => { "oidc" => "https://token.actions.githubusercontent.com" }, + "conditions" => [ + { "operator" => "string_equals", "claim" => "aud", "value" => "localhost" } + ] + }, + { + "effect" => "allow", + "principal" => { "oidc" => "https://token.actions.githubusercontent.com" }, + "conditions" => [ + { "operator" => "string_matches", "claim" => "sub", "value" => "repo:example/repo:ref:refs/tags/.*" }, + { "operator" => "string_equals", "claim" => "event_name", "value" => "" } + ] + } + ] + } + ), role.reload.as_json.slice(*expected.keys)) + end + + test "creating rubygem trusted publishers" do + rubygem = create(:rubygem, name: "rubygem0") + create(:version, rubygem: rubygem, metadata: { "source_code_uri" => "https://github.com/example/rubygem0" }) + + visit new_rubygem_trusted_publisher_path(rubygem.slug) + + assert_text "Please sign in to continue." + + sign_in + visit new_rubygem_trusted_publisher_path(rubygem.slug) + verify_session + + assert_text "Forbidden" + + create(:ownership, rubygem: rubygem, user: @user) + + visit rubygem_trusted_publishers_path(rubygem.slug) + + page.assert_selector "h1", text: "Trusted Publishers" + page.assert_text("Trusted publishers for rubygem0") + page.assert_text "NO RUBYGEM TRUSTED PUBLISHERS FOUND" + + stub_request(:get, "https://api.github.com/repos/example/rubygem0/contents/.github/workflows") + .to_return(status: 200, body: [ + { name: "ci.yml", type: "file" }, + { name: "push_rubygem.yml", type: "file" }, + { name: "push_README.md", type: "file" }, + { name: "push.yml", type: "directory" } + ].to_json, headers: { "Content-Type" => "application/json" }) + + click_button "Create" + + page.assert_selector "h1", text: "New Trusted Publisher" + + assert_field "Repository owner", with: "example" + assert_field "Repository name", with: "rubygem0" + assert_field "Workflow filename", with: "push_rubygem.yml" + assert_field "Environment", with: "" + + stub_request(:get, "https://api.github.com/users/example") + .to_return(status: 200, body: { id: "54321" }.to_json, headers: { "Content-Type" => "application/json" }) + + click_button "Create Rubygem trusted publisher" + + page.assert_text "Trusted Publisher created" + page.assert_selector "h1", text: "Trusted Publishers" + page.assert_text("Trusted publishers for rubygem0") + page.assert_text "GitHub Actions\nDelete\nGitHub Repository\nexample/rubygem0\nWorkflow Filename\npush_rubygem.yml" + end + + test "deleting rubygem trusted publishers" do + rubygem = create(:rubygem, owners: [@user]) + create(:oidc_rubygem_trusted_publisher, rubygem:) + create(:version, rubygem:) + + sign_in + visit rubygem_trusted_publishers_path(rubygem.slug) + verify_session + + click_button "Delete" + + assert_content "Trusted Publisher deleted" + assert_content "NO RUBYGEM TRUSTED PUBLISHERS FOUND" + end + + test "creating pending trusted publishers" do + rubygem = create(:rubygem, name: "rubygem0") + create(:version, rubygem: rubygem, metadata: { "source_code_uri" => "https://github.com/example/rubygem0" }) + + visit profile_oidc_pending_trusted_publishers_path + + assert_text "Please sign in to continue." + + sign_in + visit profile_oidc_pending_trusted_publishers_path + verify_session + click_button "Create" + + page.assert_selector "h1", text: "New Pending Trusted Publisher" + + click_button "Create" + + page.assert_text "can't be blank" + page.assert_selector "h1", text: "New Pending Trusted Publisher" + + page.fill_in "RubyGem name", with: "rubygem0" + page.fill_in "Repository owner", with: "example" + page.fill_in "Repository name", with: "rubygem1" + page.fill_in "Workflow filename", with: "push_rubygem.yml" + page.fill_in "Environment", with: "prod" + + stub_request(:get, "https://api.github.com/users/example") + .to_return(status: 200, body: { id: "54321" }.to_json, headers: { "Content-Type" => "application/json" }) + + click_button "Create" + + page.assert_text "RubyGem name is already in use" + page.assert_selector "h1", text: "New Pending Trusted Publisher" + + assert_field "RubyGem name", with: "rubygem0" + assert_field "Repository owner", with: "example" + assert_field "Repository name", with: "rubygem1" + assert_field "Workflow filename", with: "push_rubygem.yml" + assert_field "Environment", with: "prod" + + page.fill_in "RubyGem name", with: "rubygem1" + + click_button "Create Pending trusted publisher" + + page.assert_text "Pending Trusted Publisher created" + page.assert_selector "h1", text: "Pending Trusted Publishers" + page.assert_text <<~TEXT + rubygem1 + Delete + GitHub Actions + Valid for about 12 hours + GitHub Repository + example/rubygem1 + Workflow Filename + push_rubygem.yml + Environment + prod + TEXT + end + + test "deleting pending trusted publishers" do + create(:oidc_pending_trusted_publisher, user: @user) + + sign_in + visit profile_oidc_pending_trusted_publishers_path + verify_session + + click_button "Delete" + + assert_content "Pending Trusted Publisher deleted" + assert_content "NO PENDING TRUSTED PUBLISHERS FOUND" + end +end diff --git a/test/system/settings_test.rb b/test/system/settings_test.rb new file mode 100644 index 00000000000..aa67f60cd68 --- /dev/null +++ b/test/system/settings_test.rb @@ -0,0 +1,181 @@ +require "application_system_test_case" + +class SettingsTest < ApplicationSystemTestCase + setup do + @user = create(:user, email: "nick@example.com", password: PasswordHelpers::SECURE_TEST_PASSWORD, handle: "nick1", mail_fails: 1) + end + + def sign_in + visit sign_in_path + fill_in "Email or Username", with: @user.reload.email + fill_in "Password", with: @user.password + click_button "Sign in" + end + + def enable_otp + key = ROTP::Base32.random_base32 + @user.enable_totp!(key, :ui_only) + end + + def change_auth_level(type) + page.select type + find("#mfa-edit input[type=submit]").click + end + + def otp_key + key_regex = /( (\w{4})){8}/ + page.find_by_id("otp-key").text.match(key_regex)[0].delete("\s") + end + + test "enabling multi-factor authentication with valid otp" do + sign_in + visit edit_settings_path + click_button "Register a new device" + + assert page.has_content? "Enabling multi-factor auth" + + totp = ROTP::TOTP.new(otp_key) + page.fill_in "otp", with: totp.now + click_button "Enable" + + assert page.has_content? "Recovery codes" + + click_link "Copy to clipboard" + check "ack" + click_button "Continue" + + assert page.has_content? "You have enabled multi-factor authentication." + css = %(a[href="https://guides.rubygems.org/setting-up-multifactor-authentication"]) + + assert page.has_css?(css, visible: true) + end + + test "enabling multi-factor authentication with invalid otp" do + sign_in + visit edit_settings_path + click_button "Register a new device" + + assert page.has_content? "Enabling multi-factor auth" + + totp = ROTP::TOTP.new(ROTP::Base32.random_base32) + page.fill_in "otp", with: totp.now + click_button "Enable" + + assert page.has_content? "You have not yet enabled OTP based multi-factor authentication." + end + + test "disabling multi-factor authentication with valid otp" do + sign_in + enable_otp + visit edit_settings_path + + page.fill_in "otp", with: ROTP::TOTP.new(@user.totp_seed).now + click_button "Disable" + + assert page.has_content? "You have not yet enabled OTP based multi-factor authentication." + css = %(a[href="https://guides.rubygems.org/setting-up-multifactor-authentication"]) + + assert page.has_css?(css) + end + + test "disabling multi-factor authentication with invalid otp" do + sign_in + enable_otp + visit edit_settings_path + + key = ROTP::Base32.random_base32 + page.fill_in "otp", with: ROTP::TOTP.new(key).now + click_button "Disable" + + assert page.has_content? "You have enabled multi-factor authentication." + end + + test "disabling multi-factor authentication with recovery code" do + sign_in + visit edit_settings_path + click_button "Register a new device" + + totp = ROTP::TOTP.new(otp_key) + page.fill_in "otp", with: totp.now + click_button "Enable" + + assert page.has_content? "Recovery codes" + + recoveries = page.find(:css, ".recovery-code-list").value.split + + click_link "Copy to clipboard" + check "ack" + click_button "Continue" + page.fill_in "otp", with: recoveries.sample + click_button "Disable" + + assert page.has_content? "You have not yet enabled OTP based multi-factor authentication." + end + + test "Clicking MFA continue button without copying recovery codes creates confirm popup" do + sign_in + visit edit_settings_path + click_button "Register a new device" + + totp = ROTP::TOTP.new(otp_key) + page.fill_in "otp", with: totp.now + click_button "Enable" + check "ack" + confirm_text = page.dismiss_confirm do + click_button "Continue" + end + + assert_equal "Leave without copying recovery codes?", confirm_text + assert_equal recovery_multifactor_auth_path, page.current_path + page.accept_confirm do + click_button "Continue" + end + page.find("h1", text: "Edit settings") + + assert_equal edit_settings_path, page.current_path + end + + test "Navigating away another way without copying recovery codes creates default leave warning popup" do + sign_in + visit edit_settings_path + click_button "Register a new device" + + totp = ROTP::TOTP.new(otp_key) + page.fill_in "otp", with: totp.now + click_button "Enable" + + check "ack" + dismiss_confirm("Leave without copying recovery codes?") do + click_on "Continue" + end + + assert_equal recovery_multifactor_auth_path, page.current_path + + accept_confirm("Leave without copying recovery codes?") do + click_on "Continue" + end + + assert_equal edit_settings_path, page.current_path + end + + test "shows 'ui only' if user's level is ui_only" do + sign_in + enable_otp + visit edit_settings_path + + assert page.has_selector?("#level > option:nth-child(3)") + assert page.has_content? "UI Only" + end + + test "does not shows 'ui only' if user's level is not ui_only" do + sign_in + enable_otp + visit edit_settings_path + + page.fill_in "otp", with: ROTP::TOTP.new(@user.totp_seed).now + change_auth_level "UI and API" + + refute page.has_selector?("#level > option:nth-child(3)") + refute page.has_content? "UI Only" + end +end diff --git a/test/system/sign_in_webauthn_test.rb b/test/system/sign_in_webauthn_test.rb new file mode 100644 index 00000000000..24c8ff26395 --- /dev/null +++ b/test/system/sign_in_webauthn_test.rb @@ -0,0 +1,148 @@ +require "application_system_test_case" + +class SignInWebauthnTest < ApplicationSystemTestCase + setup do + @user = create(:user, email: "nick@example.com", password: PasswordHelpers::SECURE_TEST_PASSWORD, handle: nil) + @mfa_recovery_codes = %w[0123456789ab ba9876543210] + @mfa_user = create(:user, email: "john@example.com", password: PasswordHelpers::SECURE_TEST_PASSWORD, + mfa_level: :ui_only, totp_seed: "thisisonetotpseed", + mfa_recovery_codes: @mfa_recovery_codes) + + @authenticator = create_webauthn_credential + end + + teardown do + @authenticator&.remove! + Capybara.reset_sessions! + Capybara.use_default_driver + end + + test "sign in with webauthn mfa" do + visit sign_in_path + + fill_in "Email or Username", with: @user.email + fill_in "Password", with: @user.password + click_button "Sign in" + + assert page.has_content? "Multi-factor authentication" + assert page.has_content? "Security Device" + + click_on "Authenticate with security device" + + assert page.has_content? "Dashboard" + refute page.has_content? "We now support security devices!" + end + + test "sign in with webauthn mfa but it expired" do + visit sign_in_path + + fill_in "Email or Username", with: @user.email + fill_in "Password", with: @user.password + click_button "Sign in" + + assert page.has_content? "Multi-factor authentication" + assert page.has_content? "Security Device" + + travel 30.minutes do + click_on "Authenticate with security device" + + assert page.has_content? "Your login page session has expired." + assert page.has_content? "Sign in" + end + end + + test "sign in with webauthn mfa wrong user handle" do + visit sign_in_path + + fill_in "Email or Username", with: @user.email + fill_in "Password", with: @user.password + click_button "Sign in" + + assert page.has_content? "Multi-factor authentication" + assert page.has_content? "Security Device" + + @user.update!(webauthn_id: "a") + + click_on "Authenticate with security device" + + refute page.has_content? "Dashboard" + assert page.has_content? "Sign in" + end + + test "sign in with webauthn mfa using recovery codes" do + visit sign_in_path + + fill_in "Email or Username", with: @user.email + fill_in "Password", with: @user.password + click_button "Sign in" + + assert page.has_content? "Multi-factor authentication" + assert page.has_content? "Security Device" + + fill_in "otp", with: @mfa_recovery_codes.first + click_button "Authenticate" + + assert page.has_content? "Dashboard" + end + + test "sign in with webauthn" do + visit sign_in_path + + click_on "Authenticate with security device" + + assert page.has_content? "Dashboard" + refute page.has_content? "We now support security devices!" + end + + test "sign in with webauthn failure" do + visit sign_in_path + + @user.webauthn_credentials.find_each { |c| c.update!(external_id: "a") } + + click_on "Authenticate with security device" + + refute page.has_content? "Dashboard" + end + + test "sign in with webauthn user_handle changed failure" do + visit sign_in_path + + @user.update!(webauthn_id: "a") + + click_on "Authenticate with security device" + + refute page.has_content? "Dashboard" + assert page.has_content? "Sign in" + end + + test "sign in with webauthn does not expire" do + visit sign_in_path + + travel 30.minutes do + click_on "Authenticate with security device" + + assert page.has_content? "Dashboard" + end + end + + test "sign in with webauthn to blocked account" do + @user.block! + + visit sign_in_path + click_on "Authenticate with security device" + + refute page.has_content? "Dashboard" + assert page.has_content? "Sign in" + assert page.has_content? "Your account was blocked by rubygems team. Please email support@rubygems.org to recover your account." + end + + test "sign in with webauthn to deleted account" do + @user.update!(deleted_at: Time.zone.now) + + visit sign_in_path + click_on "Authenticate with security device" + + refute page.has_content? "Dashboard" + assert page.has_content? "Sign in" + end +end diff --git a/test/system/stats_test.rb b/test/system/stats_test.rb new file mode 100644 index 00000000000..d12e116a11d --- /dev/null +++ b/test/system/stats_test.rb @@ -0,0 +1,14 @@ +require "application_system_test_case" + +class StatsTest < ApplicationSystemTestCase + setup do + @rubygem = create(:rubygem, number: "0.0.1", downloads: 100) + end + + test "downloads animation bar" do + visit stats_path + + assert page.find(".stats__graph__gem__meter", wait: Capybara.default_max_wait_time) + assert page.has_content?(@rubygem.downloads) + end +end diff --git a/test/system/transitive_dependencies_test.rb b/test/system/transitive_dependencies_test.rb new file mode 100644 index 00000000000..fd9afd055eb --- /dev/null +++ b/test/system/transitive_dependencies_test.rb @@ -0,0 +1,55 @@ +require "application_system_test_case" + +class TransitiveDependenciesTest < ApplicationSystemTestCase + test "loading transitive dependencies using ajax" do + version_one = create(:version) + rubygem_one = version_one.rubygem + + rubygem_two = create(:rubygem) + version_two = ["1.0.2", "2.4.3", "4.5.6"].map do |ver_number| + FactoryBot.create(:version, number: ver_number, rubygem: rubygem_two) + end + version_three = create(:version) + version_four = create(:version) + + create(:dependency, requirements: ">=0", scope: :runtime, version: version_one, rubygem: rubygem_two) + + version_two.each do |ver2| + create(:dependency, requirements: ">=0", scope: :runtime, version: ver2, rubygem: version_three.rubygem) + create(:dependency, requirements: ">=0", scope: :runtime, version: ver2, rubygem: version_four.rubygem) + end + + visit rubygem_version_dependencies_path(rubygem_id: rubygem_one.slug, version_id: version_one.number) + + assert page.has_content?(rubygem_one.name) + assert page.has_content?(version_one.number) + assert page.has_content?(rubygem_two.name) + page.assert_text(version_two[2].number) + find("span.deps_expanded-link").click + + assert page.has_content?(version_four.rubygem.name) + assert page.has_content?(version_three.number) + assert page.has_content?(version_four.rubygem.name) + assert page.has_content?(version_four.number) + end + + test "loading transitive dependencies for jruby platform" do + version = create(:version, platform: "jruby") + + dep_version = create(:version, number: "1.0.0", platform: "jruby") + create(:dependency, rubygem: dep_version.rubygem, version: version) + + dep_dep_version = create(:version, platform: "jruby") + create(:dependency, requirements: ">=0", scope: :runtime, rubygem: dep_dep_version.rubygem, version: dep_version) + + visit rubygem_path(version.rubygem.slug) + click_on "Show all transitive dependencies" + + assert page.has_content?(dep_version.rubygem.name) + assert page.has_content?(dep_version.slug) + find("span.deps_expanded-link").click + + assert page.has_content?(dep_dep_version.rubygem.name) + assert page.has_content?(dep_dep_version.slug) + end +end diff --git a/test/system/webauthn_credentials_test.rb b/test/system/webauthn_credentials_test.rb new file mode 100644 index 00000000000..6930ec829ee --- /dev/null +++ b/test/system/webauthn_credentials_test.rb @@ -0,0 +1,126 @@ +require "application_system_test_case" + +class WebauthnCredentialsTest < ApplicationSystemTestCase + include ActiveJob::TestHelper + + setup do + @user = create(:user) + end + + def sign_in + visit sign_in_path + fill_in "Email or Username", with: @user.reload.email + fill_in "Password", with: @user.password + click_button "Sign in" + end + + should "have security device form" do + sign_in + visit edit_settings_path + + assert_text "Register a new security device" + assert_text "SECURITY DEVICE" + assert_text "You don't have any security devices" + assert page.has_field?("Nickname") + assert page.has_button?("Register device") + end + + should "show the security device" do + sign_in + @primary = create(:webauthn_credential, :primary, user: @user) + @backup = create(:webauthn_credential, :backup, user: @user) + visit edit_settings_path + + assert_text "SECURITY DEVICE" + assert_no_text "You don't have any security devices" + assert_text "Register a new security device" + assert_text @primary.nickname + assert_text @backup.nickname + assert page.has_button?("Delete") + assert page.has_field?("Nickname") + assert page.has_button?("Register device") + end + + should "be able to delete security devices" do + sign_in + @webauthn_credential = create(:webauthn_credential, user: @user) + visit edit_settings_path + + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + assert_text "SECURITY DEVICE" + assert_no_text "You don't have any security devices" + assert_text @webauthn_credential.nickname + + click_on "Delete" + page.accept_alert + + assert_text "You don't have any security devices" + assert_no_text @webauthn_credential.nickname + end + + webauthn_credential_removed_email = ActionMailer::Base.deliveries.find do |email| + email.to.include?(@user.email) + end + + assert_equal "Security device removed on RubyGems.org", webauthn_credential_removed_email.subject + end + + should "not delete device if confirmation alert is dismissed" do + sign_in + @webauthn_credential = create(:webauthn_credential, user: @user) + visit edit_settings_path + + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + assert_text "SECURITY DEVICE" + assert_no_text "You don't have any security devices" + assert_text @webauthn_credential.nickname + + click_on "Delete" + page.dismiss_confirm + + assert_no_text "You don't have any security devices" + assert_text @webauthn_credential.nickname + end + + webauthn_credential_removed_email = ActionMailer::Base.deliveries.find do |email| + email.to.include?(@user.email) + end + + assert_nil webauthn_credential_removed_email + end + + should "be able to create security devices" do + sign_in + visit edit_settings_path + + assert_text "You don't have any security devices" + + options = ::Selenium::WebDriver::VirtualAuthenticatorOptions.new + authenticator = page.driver.browser.add_virtual_authenticator(options) + WebAuthn::PublicKeyCredentialWithAttestation.any_instance.stubs(:verify).returns true + + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + @credential_nickname = "new cred" + fill_in "Nickname", with: @credential_nickname + click_on "Register device" + + assert page.has_content? "Recovery codes" + end + + assert_equal recovery_multifactor_auth_path, current_path + click_on "Copy to clipboard" + check "ack" + click_on "Continue" + + assert_equal edit_settings_path, current_path + + webauthn_credential_creation_email = ActionMailer::Base.deliveries.find do |email| + email.to.include?(@user.email) + end + + assert_equal "New security device added on RubyGems.org", webauthn_credential_creation_email.subject + + # Cleanup test data + authenticator.remove! + end +end diff --git a/test/system/webauthn_verification_test.rb b/test/system/webauthn_verification_test.rb new file mode 100644 index 00000000000..bc3213e4040 --- /dev/null +++ b/test/system/webauthn_verification_test.rb @@ -0,0 +1,228 @@ +require "application_system_test_case" + +class WebAuthnVerificationTest < ApplicationSystemTestCase + setup do + @user = create(:user) + create_webauthn_credential + @verification = create(:webauthn_verification, user: @user, otp: nil, otp_expires_at: nil) + @port = 5678 + @mock_client = MockClientServer.new(@port) + end + + test "when verifying webauthn credential" do + visit webauthn_verification_path(webauthn_token: @verification.path_token, params: { port: @port }) + + assert_match "Authenticate with Security Device", page.html + assert_match "Authenticating as #{@user.handle}", page.html + + click_on "Authenticate" + + assert redirect_to("http://localhost:#{@port}?code=#{@verification.otp}") + assert redirect_to(successful_verification_webauthn_verification_path) + assert page.has_content?("Success!") + assert_link_is_expired + assert_successful_verification_not_found + end + + test "when verifying webauthn credential on safari" do + assert_poll_status("pending") + visit webauthn_verification_path(webauthn_token: @verification.path_token, params: { port: @port }) + + assert_match "Authenticate with Security Device", page.html + assert_match "Authenticating as #{@user.handle}", page.html + + click_on "Authenticate" + + Browser::Chrome.any_instance.stubs(:safari?).returns true + + assert page.has_content?("Success!") + assert_current_path(successful_verification_webauthn_verification_path) + + assert_link_is_expired + assert_poll_status("success") + assert_successful_verification_not_found + end + + test "when client closes connection during verification" do + visit webauthn_verification_path(webauthn_token: @verification.path_token, params: { port: @port }) + + assert_match "Authenticate with Security Device", page.html + assert_match "Authenticating as #{@user.handle}", page.html + + @mock_client.kill_server + click_on "Authenticate" + + assert redirect_to("http://localhost:#{@port}?code=#{@verification.otp}") + assert redirect_to(failed_verification_webauthn_verification_path) + assert page.has_content?("Failed to fetch") + assert page.has_content?("Please close this browser and try again.") + assert_link_is_expired + assert_failed_verification_not_found + end + + test "when port given does not match the client port" do + wrong_port = 1111 + visit webauthn_verification_path(webauthn_token: @verification.path_token, params: { port: wrong_port }) + + assert_match "Authenticate with Security Device", page.html + assert_match "Authenticating as #{@user.handle}", page.html + + click_on "Authenticate" + + assert redirect_to("http://localhost:#{wrong_port}?code=#{@verification.otp}") + assert redirect_to(failed_verification_webauthn_verification_path) + assert page.has_content?("Failed to fetch") + assert page.has_content?("Please close this browser and try again.") + assert_link_is_expired + assert_failed_verification_not_found + end + + test "when there is a client error" do + @mock_client.response = @mock_client.bad_request_response + visit webauthn_verification_path(webauthn_token: @verification.path_token, params: { port: @port }) + + assert_match "Authenticate with Security Device", page.html + assert_match "Authenticating as #{@user.handle}", page.html + + click_on "Authenticate" + + assert redirect_to(failed_verification_webauthn_verification_path) + assert page.has_content?("Failed to fetch") + assert page.has_content?("Please close this browser and try again.") + assert_link_is_expired + assert_failed_verification_not_found + end + + test "when webauthn verification is expired during verification" do + visit webauthn_verification_path(webauthn_token: @verification.path_token, params: { port: @port }) + + travel 3.minutes do + assert_match "Authenticate with Security Device", page.html + assert_match "Authenticating as #{@user.handle}", page.html + + click_on "Authenticate" + + assert redirect_to(failed_verification_webauthn_verification_path) + assert page.has_content?("The token in the link you used has either expired or been used already.") + assert page.has_content?("Please close this browser and try again.") + assert_failed_verification_not_found + end + end + + def teardown + @mock_client.kill_server + @authenticator&.remove! + Capybara.reset_sessions! + Capybara.use_default_driver + end + + private + + def assert_link_is_expired + visit webauthn_verification_path(webauthn_token: @verification.path_token, params: { port: @port }) + + assert page.has_content?("The token in the link you used has either expired or been used already.") + end + + def assert_poll_status(status) + @api_key ||= create(:api_key, key: "12345", scopes: %i[push_rubygem], owner: @user) + + Capybara.current_driver = :rack_test + page.driver.header "AUTHORIZATION", "12345" + + visit status_api_v1_webauthn_verification_path(webauthn_token: @verification.path_token, format: :json) + + assert_equal status, JSON.parse(page.text)["status"] + fullscreen_headless_chrome_driver + end + + def assert_successful_verification_not_found + visit successful_verification_webauthn_verification_path + + assert page.has_content?("Page not found.") + end + + def assert_failed_verification_not_found + visit failed_verification_webauthn_verification_path + + assert page.has_content?("Page not found.") + end + + class MockClientServer + attr_writer :response + + def initialize(port) + @port = port + @server = TCPServer.new(@port) + @response = success_response + create_socket + end + + def create_socket + @thread = Thread.new do + loop do + socket = @server.accept + request_line = socket.gets + + method, _req_uri, _protocol = request_line.split + if method == "OPTIONS" + socket.print options_response + socket.close + next # will be GET + else + socket.print @response + socket.close + break + end + end + ensure + @server.close + end + @thread.abort_on_exception = true + @thread.report_on_exception = false + end + + def kill_server + Thread.kill(@thread) + end + + def options_response + <<~RESPONSE + HTTP/1.1 204 No Content\r + connection: close\r + access-control-allow-origin: *\r + access-control-allow-methods: POST\r + access-control-allow-headers: Content-Type, Authorization, x-csrf-token\r + \r + RESPONSE + end + + def success_response + <<~RESPONSE + HTTP/1.1 200 OK\r + connection: close\r + access-control-allow-origin: *\r + access-control-allow-methods: POST\r + access-control-allow-headers: Content-Type, Authorization, x-csrf-token\r + content-type: text/plain\r + content-length: 7\r + \r + success + RESPONSE + end + + def bad_request_response + <<~RESPONSE + HTTP/1.1 400 Bad Request\r + connection: close\r + access-control-allow-origin: rubygems.example\r + access-control-allow-methods: POST\r + access-control-allow-headers: Content-Type, Authorization, x-csrf-token\r + content-type: text/plain\r + content-length: 22\r + \r + missing code parameter + RESPONSE + end + end +end diff --git a/test/tasks/maintenance/backfill_gem_platform_to_versions_task_test.rb b/test/tasks/maintenance/backfill_gem_platform_to_versions_task_test.rb new file mode 100644 index 00000000000..2140449a147 --- /dev/null +++ b/test/tasks/maintenance/backfill_gem_platform_to_versions_task_test.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "test_helper" + +class Maintenance::BackfillGemPlatformToVersionsTaskTest < ActiveSupport::TestCase + context "#collection" do + should "return versions without a gem_platform" do + create(:version) + b = create(:version) + b.update_attribute(:gem_platform, nil) + + assert_equal [b], Maintenance::BackfillGemPlatformToVersionsTask.collection.to_a + end + end + + context "#process" do + include SemanticLogger::Test::Minitest + + should "update the gem_platform for ruby platform" do + v = create(:version, rubygem: build(:rubygem, name: "rubygem"), number: "1", platform: "ruby") + v.update_attribute(:gem_platform, nil) + + Maintenance::BackfillGemPlatformToVersionsTask.process(v) + + assert_equal "ruby", v.reload.gem_platform + assert_equal "rubygem-1", v.gem_full_name + assert_equal "rubygem-1", v.full_name + end + + should "update the gem_platform for non-ruby platform" do + v = create(:version, rubygem: build(:rubygem, name: "rubygem"), number: "1", platform: "jruby") + v.update_attribute(:gem_platform, nil) + + Maintenance::BackfillGemPlatformToVersionsTask.process(v) + + assert_equal "java", v.reload.gem_platform + assert_equal "rubygem-1-java", v.gem_full_name + assert_equal "rubygem-1-jruby", v.full_name + end + + should "not error on gem_platform collision" do + rubygem = create(:rubygem, name: "rubygem") + v1 = build(:version, rubygem: rubygem, number: "1", platform: "x64-darwin19", gem_platform: nil).tap { |v| v.save(validate: false) } + v2 = build(:version, rubygem: rubygem, number: "1", platform: "x64-darwin-19", gem_platform: nil).tap { |v| v.save(validate: false) } + + Maintenance::BackfillGemPlatformToVersionsTask.process(v1) + Maintenance::BackfillGemPlatformToVersionsTask.process(v2) + + assert_equal "x64-darwin-19", v1.reload.gem_platform + assert_equal "x64-darwin-19", v2.reload.gem_platform + + assert_equal "rubygem-1-x64-darwin-19", v1.gem_full_name + assert_equal "rubygem-1-x64-darwin-19", v2.gem_full_name + + assert_equal "rubygem-1-x64-darwin19", v1.full_name + assert_equal "rubygem-1-x64-darwin-19", v2.full_name + end + + should "handle full_name casing collisions" do + rubygem1 = create(:rubygem, name: "rubygem") + rubygem2 = create(:rubygem) + rubygem2.update_attribute(:name, "RubyGem") + + save = lambda do |v| + v.validate + v.gem_platform = v.gem_full_name = nil + v.save!(validate: false) + end + + v1 = build(:version, rubygem: rubygem1, number: "1", platform: "ruby").tap(&save) + v2 = build(:version, rubygem: rubygem2, number: "1", platform: "ruby").tap(&save) + + logger = SemanticLogger::Test::CaptureLogEvents.new + Maintenance::BackfillGemPlatformToVersionsTask.stubs(:logger).returns(logger) + + Maintenance::BackfillGemPlatformToVersionsTask.process(v1) + Maintenance::BackfillGemPlatformToVersionsTask.process(v2) + + assert_equal "ruby", v1.reload.gem_platform + assert_equal "ruby", v2.reload.gem_platform + + assert_equal "rubygem-1", v1.gem_full_name + assert_equal "RubyGem-1", v2.gem_full_name + + assert_equal "rubygem-1", v1.full_name + assert_equal "RubyGem-1", v2.full_name + + assert_semantic_logger_event( + logger.events[0], + level: :warn, + message_includes: "Version RubyGem-1 failed validation setting gem_platform to \"ruby\" but was saved without validation" + ) + assert_equal 1, logger.events.size + end + end +end diff --git a/test/tasks/maintenance/backfill_linkset_links_to_version_metadata_task_test.rb b/test/tasks/maintenance/backfill_linkset_links_to_version_metadata_task_test.rb new file mode 100644 index 00000000000..258ed6d2b38 --- /dev/null +++ b/test/tasks/maintenance/backfill_linkset_links_to_version_metadata_task_test.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require "test_helper" + +class Maintenance::BackfillLinksetLinksToVersionMetadataTaskTest < ActiveSupport::TestCase + context "#collection" do + should "return all versions" do + assert_equal Version.count, Maintenance::BackfillLinksetLinksToVersionMetadataTask.collection.count + end + end + + context "#process" do + context "without a linkset" do + setup do + @version = create(:version) + @rubygem = @version.rubygem + @rubygem.update!(linkset: nil) + end + + should "not change version metadata" do + assert_no_changes "@version.reload.metadata" do + Maintenance::BackfillLinksetLinksToVersionMetadataTask.process(@version) + end + end + end + + context "with a linkset and version metadata uris" do + setup do + @version = create( + :version, + metadata: { + "source_code_uri" => "https://example.com/source", + "documentation_uri" => "https://example.com/docs", + "foo" => "bar" + } + ) + @rubygem = @version.rubygem + @rubygem.linkset.update!("home" => "https://example.com/home", + "wiki" => "https://example.com/wiki") + end + + should "only update the home uri" do + Maintenance::BackfillLinksetLinksToVersionMetadataTask.process(@version) + + assert_equal({ + "source_code_uri" => "https://example.com/source", + "documentation_uri" => "https://example.com/docs", + "foo" => "bar", + "homepage_uri" => "https://example.com/home" + }, @version.reload.metadata) + end + + should "not update the home uri when present in metadata" do + @version.metadata["homepage_uri"] = "https://example.com/home/custom" + @version.save! + + Maintenance::BackfillLinksetLinksToVersionMetadataTask.process(@version) + + assert_equal({ + "source_code_uri" => "https://example.com/source", + "documentation_uri" => "https://example.com/docs", + "foo" => "bar", + "homepage_uri" => "https://example.com/home/custom" + }, @version.reload.metadata) + end + end + + context "with a linkset and no version metadata uris" do + setup do + @version = create(:version, metadata: { "foo" => "bar" }) + @rubygem = @version.rubygem + @rubygem.linkset.update!("home" => "https://example.com/home", + "wiki" => "https://example.com/wiki") + end + + should "update the version metadata" do + Maintenance::BackfillLinksetLinksToVersionMetadataTask.process(@version) + + assert_equal({ + "wiki_uri" => "https://example.com/wiki", + "foo" => "bar", + "homepage_uri" => "https://example.com/home", + "bug_tracker_uri" => "http://example.com", + "source_code_uri" => "http://example.com", + "mailing_list_uri" => "http://example.com", + "documentation_uri" => "http://example.com" + }, @version.reload.metadata) + end + end + end +end diff --git a/test/tasks/maintenance/backfill_spec_sha256_task_test.rb b/test/tasks/maintenance/backfill_spec_sha256_task_test.rb new file mode 100644 index 00000000000..93727f05e97 --- /dev/null +++ b/test/tasks/maintenance/backfill_spec_sha256_task_test.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "test_helper" + +class Maintenance::BackfillSpecSha256TaskTest < ActiveSupport::TestCase + context "#collection" do + should "return versions without spec_sha256" do + create(:version) + b = create(:version, spec_sha256: nil) + create(:version, spec_sha256: nil, indexed: false) + + assert_equal [b], Maintenance::BackfillSpecSha256Task.collection.to_a + end + end + + context "#process" do + include SemanticLogger::Test::Minitest + + setup do + @rubygem = create(:rubygem, name: "rubygem") + end + + teardown do + RubygemFs.mock! + end + + should "update the spec sha256" do + RubygemFs.instance.store("quick/Marshal.4.8/rubygem-1.gemspec.rz", "spec contents") + + v = create(:version, rubygem: @rubygem, number: "1", platform: "ruby", spec_sha256: nil) + Maintenance::BackfillSpecSha256Task.process(v) + + assert_equal "Ry6N90Xp7Or9qGoWziaaTotD1K7vOAonnRAAPjXCzic=", v.reload.spec_sha256 + end + + should "log if spec is missing" do + v = create(:version, rubygem: @rubygem, number: "1", platform: "ruby", spec_sha256: nil) + logger = SemanticLogger::Test::CaptureLogEvents.new + Maintenance::BackfillSpecSha256Task.stubs(:logger).returns(logger) + + assert_no_changes "v.reload.spec_sha256" do + Maintenance::BackfillSpecSha256Task.process(v) + end + + assert_semantic_logger_event( + logger.events[1], + level: :error, + message_includes: "Could not find quick/Marshal.4.8/rubygem-1.gemspec.rz" + ) + assert_equal 2, logger.events.size + end + + should "not update the spec sha256 if it is already set" do + RubygemFs.instance.store("quick/Marshal.4.8/rubygem-1.gemspec.rz", "spec contents") + + v = create(:version, rubygem: @rubygem, number: "1", platform: "ruby", spec_sha256: "Ry6N90Xp7Or9qGoWziaaTotD1K7vOAonnRAAPjXCzic=") + Maintenance::BackfillSpecSha256Task.process(v) + + assert_equal "Ry6N90Xp7Or9qGoWziaaTotD1K7vOAonnRAAPjXCzic=", v.reload.spec_sha256 + end + + should "error if spec_sha256 is incorrect" do + RubygemFs.instance.store("quick/Marshal.4.8/rubygem-1.gemspec.rz", "spec contents 2") + + v = create(:version, rubygem: @rubygem, number: "1", platform: "ruby", spec_sha256: "Ry6N90Xp7Or9qGoWziaaTotD1K7vOAonnRAAPjXCzic=") + assert_no_changes "v.reload.spec_sha256" do + e = assert_raise(RuntimeError) { Maintenance::BackfillSpecSha256Task.process(v) } + + assert_includes e.message, "Version rubygem-1 has incorrect spec_sha256" + end + end + end +end diff --git a/test/tasks/maintenance/upload_info_files_to_s3_task_test.rb b/test/tasks/maintenance/upload_info_files_to_s3_task_test.rb new file mode 100644 index 00000000000..dbdc1559b8e --- /dev/null +++ b/test/tasks/maintenance/upload_info_files_to_s3_task_test.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "test_helper" + +class Maintenance::UploadInfoFilesToS3TaskTest < ActiveSupport::TestCase + include ActiveJob::TestHelper + + test "#process performs a task iteration" do + rubygem = create(:rubygem) + assert_enqueued_jobs 1, only: UploadInfoFileJob do + assert_enqueued_with(job: UploadInfoFileJob, args: [{ rubygem_name: rubygem.name }]) do + Maintenance::UploadInfoFilesToS3Task.process(rubygem) + end + end + end + + test "#collection returns the elements to process" do + create(:rubygem) + rubygem = create(:rubygem) + create(:version, rubygem: rubygem) + + assert_same_elements [rubygem], Maintenance::UploadInfoFilesToS3Task.collection + end +end diff --git a/test/tasks/maintenance/user_totp_seed_empty_to_nil_task_test.rb b/test/tasks/maintenance/user_totp_seed_empty_to_nil_task_test.rb new file mode 100644 index 00000000000..46985e552e6 --- /dev/null +++ b/test/tasks/maintenance/user_totp_seed_empty_to_nil_task_test.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "test_helper" + +class Maintenance::UserTotpSeedEmptyToNilTaskTest < ActiveSupport::TestCase + test "#process performs a task iteration" do + element = create(:user, totp_seed: "") + Maintenance::UserTotpSeedEmptyToNilTask.process(element) + + assert_nil element.reload.totp_seed + + element = create(:user, :mfa_enabled) + assert_no_changes -> { element.reload.totp_seed } do + Maintenance::UserTotpSeedEmptyToNilTask.process(element) + end + end + + test "#count returns the number of elements to process" do + create(:user, totp_seed: "") + create(:user, :mfa_enabled) + + assert_equal 1, Maintenance::UserTotpSeedEmptyToNilTask.count + end +end diff --git a/test/tasks/maintenance/verify_gem_contents_in_fs_task_test.rb b/test/tasks/maintenance/verify_gem_contents_in_fs_task_test.rb new file mode 100644 index 00000000000..0926ff792de --- /dev/null +++ b/test/tasks/maintenance/verify_gem_contents_in_fs_task_test.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require "test_helper" + +class Maintenance::VerifyGemContentsInFsTaskTest < ActiveSupport::TestCase + def task(**attrs) + task = Maintenance::VerifyGemContentsInFsTask.new + task.assign_attributes(attrs) + task.stubs(logger: SemanticLogger::Test::CaptureLogEvents.new) + task + end + + teardown do + RubygemFs.mock! + end + + context "#process" do + include SemanticLogger::Test::Minitest + + should "not error without anything uploaded" do + version = create(:version) + @task = task + @task.process(version) + + assert_equal 2, @task.logger.events.size + assert_semantic_logger_event( + @task.logger.events[0], + level: :warn, + message_includes: "is missing gem contents" + ) + assert_semantic_logger_event( + @task.logger.events[1], + level: :warn, + message_includes: "is missing spec contents" + ) + end + + should "not error when checksums match" do + gem = "foo-1.0.0.gem" + gemspec = "#{gem}spec" + sha256 = Digest::SHA256.base64digest(gem) + spec_sha256 = Digest::SHA256.base64digest(gemspec) + version = create(:version, sha256:, spec_sha256:) + RubygemFs.instance.store("gems/#{version.full_name}.gem", gem) + RubygemFs.instance.store("quick/Marshal.4.8/#{version.full_name}.gemspec.rz", gemspec) + + @task = task + @task.process(version) + + assert_empty @task.logger.events + end + + should "error when checksums do not match" do + sha256 = Digest::SHA256.base64digest("abcd - other") + spec_sha256 = Digest::SHA256.base64digest("defg - other") + version = create(:version, sha256:, spec_sha256:) + RubygemFs.instance.store("gems/#{version.full_name}.gem", "abcd") + RubygemFs.instance.store("quick/Marshal.4.8/#{version.full_name}.gemspec.rz", "defg") + + @task = task + @task.process(version) + + assert_equal 2, @task.logger.events.size + assert_semantic_logger_event( + @task.logger.events[0], + level: :error, + message_includes: "has incorrect checksum (expected #{sha256}, got #{Digest::SHA256.base64digest('abcd')})" + ) + assert_semantic_logger_event( + @task.logger.events[1], + level: :error, + message_includes: "has incorrect checksum (expected #{spec_sha256}, got #{Digest::SHA256.base64digest('defg')})" + ) + end + end + + context "#collection" do + should "return all versions when no filters are provided" do + create_list(:version, 10) + + assert_equal 10, task.collection.count + end + + should "filter by full name" do + create_list(:version, 10) + + assert_empty task(full_name_pattern: "^.$").collection + end + + should "filter by rubygem name" do + create(:version, rubygem: create(:rubygem, name: "a")) + create(:version, rubygem: create(:rubygem, name: "abcd")) + + assert_equal 1, task(gem_name_pattern: "^.$").collection.count + end + end + + context "#valid?" do + should "return true when no patterns given" do + assert_predicate task, :valid? + end + + should "return true when patterns are valid" do + assert_predicate task(gem_name_pattern: "^.+$", full_name_pattern: "-\\d"), :valid? + end + + should "return false when patterns are not valid" do + refute_predicate task(gem_name_pattern: "(", full_name_pattern: "["), :valid? + end + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index e690951f88d..f381d4f6743 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,5 +1,17 @@ require "simplecov" -SimpleCov.start "rails" +SimpleCov.start "rails" do + add_filter "lib/tasks" + add_filter "lib/rails_development_log_formatter.rb" + + if ENV["CI"] + require "simplecov-cobertura" + formatter SimpleCov::Formatter::CoberturaFormatter + + # Avo tests are super fragile :'( + require "minitest/retry" + Minitest::Retry.use! + end +end ENV["RAILS_ENV"] ||= "test" require File.expand_path("../config/environment", __dir__) @@ -9,14 +21,62 @@ require "capybara/rails" require "capybara/minitest" require "clearance/test_unit" -require "shoulda" +require "webauthn/fake_client" +require "shoulda/context" +require "shoulda/matchers" +require "helpers/admin_helpers" +require "helpers/api_policy_helpers" require "helpers/gem_helpers" require "helpers/email_helpers" require "helpers/es_helper" require "helpers/password_helpers" +require "helpers/policy_helpers" +require "helpers/webauthn_helpers" +require "helpers/oauth_helpers" +require "helpers/avo_helpers" +require "webmock/minitest" +require "phlex/testing/rails/view_helper" + +# setup license early since some tests are testing Avo outside of requests +# and license is set with first request +Avo::App.license = Avo::Licensing::LicenseManager.new(Avo::Licensing::HQ.new.response).license + +WebMock.disable_net_connect!( + allow_localhost: true, + allow: [ + "chromedriver.storage.googleapis.com", + "search" # Devcontainer OpenSearch container + ] +) +WebMock.globally_stub_request(:after_local_stubs) do |request| + avo_request_pattern = WebMock::RequestPattern.new(:post, "https://avohq.io/api/v1/licenses/check") + if avo_request_pattern.matches?(request) + { status: 200, body: { id: :pro, valid: true, payload: {} }.to_json, + headers: { "Content-Type" => "application/json" } } + end + + if WebMock::RequestPattern.new(:get, Addressable::Template.new("https://secure.gravatar.com/avatar/{hash}.png?d=404&r=PG&s={size}")).matches?(request) + { status: 404, body: "", headers: {} } + end +end + +Capybara.default_max_wait_time = 2 +Capybara.app_host = "#{Gemcutter::PROTOCOL}://#{Gemcutter::HOST}" +Capybara.always_include_port = true +Capybara.server_port = 31_337 +Capybara.server = :puma, { Silent: true } + +GoodJob::Execution.delete_all RubygemFs.mock! Aws.config[:stub_responses] = true +Mocha.configure do |c| + c.strict_keyword_argument_matching = true +end + +Rubygem.searchkick_reindex(import: false) + +OmniAuth.config.test_mode = true class ActiveSupport::TestCase include FactoryBot::Syntax::Methods @@ -24,13 +84,27 @@ class ActiveSupport::TestCase include EmailHelpers include PasswordHelpers + parallelize_setup do |_worker| + SemanticLogger.reopen + end + setup do I18n.locale = :en Rails.cache.clear Rack::Attack.cache.store.clear - # Don't connect to the Pwned Passwords API in tests - Pwned.stubs(:pwned?).returns(false) + Unpwn.offline = true + OmniAuth.config.mock_auth.clear + + @launch_darkly = LaunchDarkly::Integrations::TestData.data_source + config = LaunchDarkly::Config.new(data_source: @launch_darkly, send_events: false) + Rails.configuration.launch_darkly_client = LaunchDarkly::LDClient.new("", config) + + ActionMailer::Base.deliveries.clear + end + + teardown do + Rails.configuration.launch_darkly_client.close end def page @@ -38,7 +112,9 @@ def page end def requires_toxiproxy - skip("Toxiproxy is not running, but was required for this test.") unless Toxiproxy.running? + return if Toxiproxy.running? + raise "Toxiproxy not running, but REQUIRE_TOXIPROXY was set." if ENV["REQUIRE_TOXIPROXY"] + skip("Toxiproxy is not running, but was required for this test.") end def assert_changed(object, *attributes) @@ -48,26 +124,208 @@ def assert_changed(object, *attributes) attributes.each do |attribute| original = original_attributes[attribute] latest = reloaded_object.send(attribute) + assert_not_equal original, latest, "Expected #{object.class} #{attribute} to change but still #{latest}" end end + def assert_event(tag, expected_additional, actual) + refute_nil actual, "Expected event with tag #{tag} but none found" + assert_equal tag, actual.tag + user_agent_info = actual.additional.user_agent_info + + assert_equal actual.additional_type.new(user_agent_info:, **expected_additional), actual.additional + end + def headless_chrome_driver Capybara.current_driver = :selenium_chrome_headless Capybara.default_max_wait_time = 2 Selenium::WebDriver.logger.level = :error end + + def fullscreen_headless_chrome_driver + headless_chrome_driver + driver = page.driver + fullscreen_width = 1200 + fullscreen_height = 1000 + driver.resize_window_to(driver.current_window_handle, fullscreen_width, fullscreen_height) + end + + def create_webauthn_credential + fullscreen_headless_chrome_driver + + visit sign_in_path + fill_in "Email or Username", with: @user.reload.email + fill_in "Password", with: @user.password + click_button "Sign in" + + @authenticator = create_webauthn_credential_while_signed_in + + find(:css, ".header__popup-link").click + click_on "Sign out" + + @authenticator + end + + def create_webauthn_credential_while_signed_in + visit edit_settings_path + + options = ::Selenium::WebDriver::VirtualAuthenticatorOptions.new( + resident_key: true, + user_verification: true, + user_verified: true + ) + @authenticator = page.driver.browser.add_virtual_authenticator(options) + + credential_nickname = "new cred" + fill_in "Nickname", with: credential_nickname + click_on "Register device" + + click_on "Copy to clipboard" + @mfa_recovery_codes = find(:css, ".recovery-code-list").value.split + + check "ack" + click_on "Continue" + + visit edit_settings_path + find("div", text: credential_nickname, match: :first) + + @user.reload + @authenticator + end + + def setup_rstuf + @original_rstuf_enabled = Rstuf.enabled + @original_base_url = Rstuf.base_url + Rstuf.base_url = "https://rstuf.example.com" + Rstuf.enabled = true + end + + def teardown_rstuf + Rstuf.enabled = @original_rstuf_enabled + Rstuf.base_url = @original_base_url + end +end + +class ActionController::TestCase + def process(...) + Prosopite.scan do + super + end + end + + def verified_sign_in_as(user) + sign_in_as(user) + session[:verification] = 10.minutes.from_now + session[:verified_user] = user.id + end + + def assert_text(text, context = page) + assert context.has_content?(text), "page is missing content #{text}" + end + + def refute_text(text) + refute page.has_content?(text), "page has unexpected content #{text}" + end + + def assert_selector(selector) + assert page.has_selector?(selector), "page is missing selector #{selector}" + end + + def refute_selector(selector) + refute page.has_selector?(selector), "page has unexpected selector #{selector}" + end end class ActionDispatch::IntegrationTest + include OauthHelpers setup { host! Gemcutter::HOST } end -Capybara.app_host = "#{Gemcutter::PROTOCOL}://#{Gemcutter::HOST}" -Capybara.always_include_port = true + +Gemcutter::Application.load_tasks + +# Force loading of ActionDispatch::SystemTesting::* helpers +_ = ActionDispatch::SystemTestCase class SystemTest < ActionDispatch::IntegrationTest include Capybara::DSL + include Capybara::Minitest::Assertions + include ActionDispatch::SystemTesting::TestHelpers::ScreenshotHelper + include ActionDispatch::SystemTesting::TestHelpers::SetupAndTeardown + + setup do + Capybara.current_driver = :rack_test + end +end + +class AdminPolicyTestCase < ActiveSupport::TestCase + def setup + @authorization_client = Admin::AuthorizationClient.new + end + + def assert_authorizes(user, record, action) + assert @authorization_client.authorize(user, record, action, policy_class: policy_class) + rescue Avo::NotAuthorizedError + policy_class ||= policy!(user, record).class + + flunk("Expected #{policy_class} to authorize #{action} on #{record} for #{user}") + end + + def refute_authorizes(user, record, action) + assert_raise(Avo::NotAuthorizedError) do + @authorization_client.authorize(user, record, action, policy_class: policy_class) + end + end + + def policy_class + nil + end + + def policy!(user, record) + @authorization_client.policy!(user, record) + end + + def policy_scope!(user, record) + @authorization_client.apply_policy(user, record, policy_class: policy_class) + end +end + +class ApiPolicyTestCase < ActiveSupport::TestCase + include ApiPolicyHelpers +end + +class PolicyTestCase < ActiveSupport::TestCase + include PolicyHelpers +end + +class ComponentTest < ActiveSupport::TestCase + include Phlex::Testing::Rails::ViewHelper + include Capybara::Minitest::Assertions + + attr_reader :page + + def render(...) + response = super + app = ->(_env) { [200, { "Content-Type" => "text/html" }, [response]] } + session = Capybara::Session.new(:rack_test, app) + session.visit("/") + @page = session.document + end + + def preview(path = preview_path, scenario: :default, **params) + preview = Lookbook::Engine.previews.find_by_path(path) + + refute_nil preview, "Preview not found: #{path}" + render_args = preview.render_args(scenario, params:) + component = render_args.fetch(:component) + yield component if block_given? + render component + end + + def preview_path + self.class.name.sub(/ComponentTest$/, "").underscore + end end Shoulda::Matchers.configure do |config| diff --git a/test/unit/api_key_test.rb b/test/unit/api_key_test.rb deleted file mode 100644 index 5044768631c..00000000000 --- a/test/unit/api_key_test.rb +++ /dev/null @@ -1,44 +0,0 @@ -require "test_helper" - -class ApiKeyTest < ActiveSupport::TestCase - should belong_to :user - should validate_presence_of(:name) - should validate_presence_of(:user) - should validate_presence_of(:hashed_key) - - should "be valid with factory" do - assert build(:api_key).valid? - end - - should "be invalid when name is empty string" do - api_key = build(:api_key, name: "") - refute api_key.valid? - assert_contains api_key.errors[:name], "can't be blank" - end - - should "be invalid when name is longer than Gemcutter::MAX_FIELD_LENGTH" do - api_key = build(:api_key, name: "aa" * Gemcutter::MAX_FIELD_LENGTH) - refute api_key.valid? - assert_contains api_key.errors[:name], "is too long (maximum is 255 characters)" - end - - context "#scope" do - setup do - @api_key = create(:api_key, index_rubygems: true, push_rubygem: true) - end - - should "return enabled scopes" do - assert_equal %i[index_rubygems push_rubygem], @api_key.enabled_scopes - end - end - - context "show_dashboard scope" do - should "be valid when enabled exclusively" do - assert build(:api_key, show_dashboard: true).valid? - end - - should "be invalid when enabled with any other scope" do - refute build(:api_key, show_dashboard: true, push_rubygem: true).valid? - end - end -end diff --git a/test/unit/avo/actions/base_action_test.rb b/test/unit/avo/actions/base_action_test.rb new file mode 100644 index 00000000000..fd5f46600a5 --- /dev/null +++ b/test/unit/avo/actions/base_action_test.rb @@ -0,0 +1,199 @@ +require "test_helper" + +class BaseActionTest < ActiveSupport::TestCase + class DestroyerAction < BaseAction + class ActionHandler < ActionHandler + def handle_model(model) + model.destroy! + end + end + end + + class EmptyAction < BaseAction + class ActionHandler < ActionHandler + def handle_model(model) + end + end + end + + class WebHookCreateAction < BaseAction + class ActionHandler < BaseAction::ActionHandler + def handle_model(user) + user.web_hooks.create(url: "https://example.com/path") + end + end + end + + make_my_diffs_pretty! + + setup do + @view_context = mock + @avo = mock + @view_context.stubs(:avo).returns(@avo) + @avo.stubs(:resources_audit_path).returns("resources_audit_path") + + ::Avo::App.init request: nil, context: nil, current_user: nil, view_context: @view_context, params: {} + end + + test "handles errors" do + raises_on_each = Class.new do + def each + raise "Cannot enumerate" + end + end.new + action = BaseAction.new + + args = { + fields: { + comment: "Sufficiently detailed" + }, + current_user: create(:admin_github_user, :is_admin), + resource: nil, + models: raises_on_each + } + + action.handle(**args) + + assert_equal [{ type: :error, body: "Cannot enumerate" }], action.response[:messages] + assert action.response[:keep_modal_open] + end + + test "tracks deletions" do + action = DestroyerAction.new + webhook = create(:global_web_hook) + admin = create(:admin_github_user, :is_admin) + + args = { + fields: { + comment: "Sufficiently detailed" + }, + current_user: admin, + resource: nil, + models: [webhook] + } + + action.handle(**args) + + assert_empty action.response[:messages] + + assert_predicate webhook, :destroyed? + + audit = Audit.sole + as_json = audit.as_json.except("created_at", "updated_at") + as_json.dig("audited_changes", "records").values.sole["changes"].except!("created_at", "updated_at") + + assert_equal({ + "id" => audit.id, + "auditable_type" => "WebHook", + "auditable_id" => webhook.id, + "admin_github_user_id" => admin.id, + "audited_changes" => { + "records" => { + webhook.to_global_id.uri.to_s => { + "changes" => { "id" => [webhook.id, nil], "failure_count" => [0, nil], "user_id" => [webhook.user.id, nil], "url" => [webhook.url, nil], + "successes_since_last_failure" => [0, nil], "failures_since_last_success" => [0, nil] }, + "unchanged" => { "rubygem_id" => nil, "disabled_reason" => nil, "disabled_at" => nil, "last_success" => nil, "last_failure" => nil } + } + }, + "fields" => {}, + "arguments" => {}, + "models" => [webhook.to_global_id.uri.to_s] + }, + "comment" => "Sufficiently detailed", + "action" => "Destroyer action" + }, as_json) + end + + test "tracks no changes" do + action = EmptyAction.new + user = create(:user) + admin = create(:admin_github_user, :is_admin) + + args = { + fields: { + comment: "Sufficiently detailed" + }, + current_user: admin, + resource: nil, + models: [user] + } + + action.handle(**args) + + assert_empty action.response[:messages] + + audit = Audit.sole + as_json = audit.as_json.except("created_at", "updated_at") + + assert_equal({ + "id" => audit.id, + "auditable_type" => "User", + "auditable_id" => user.id, + "admin_github_user_id" => admin.id, + "audited_changes" => { + "records" => {}, + "fields" => {}, + "arguments" => {}, + "models" => [user.to_global_id.uri.to_s] + }, + "comment" => "Sufficiently detailed", + "action" => "Empty action" + }, as_json) + end + + test "tracks insertions" do + action = WebHookCreateAction.new + user = create(:user) + admin = create(:admin_github_user, :is_admin) + + args = { + fields: { + comment: "Sufficiently detailed" + }, + current_user: admin, + resource: nil, + models: [user] + } + + action.handle(**args) + + assert_empty action.response[:messages] + + audit = Audit.sole + webhook = WebHook.sole + as_json = audit.as_json.except("created_at", "updated_at") + as_json.dig("audited_changes", "records").values.sole["changes"].except!("created_at", "updated_at") + + assert_equal({ + "id" => audit.id, + "auditable_type" => "User", + "auditable_id" => user.id, + "admin_github_user_id" => admin.id, + "audited_changes" => { + "records" => { + webhook.to_global_id.uri.to_s => { + "changes" => { + "id" => [nil, webhook.id], + "user_id" => [nil, user.id], + "url" => [nil, webhook.url], + "failure_count" => [nil, 0], + "rubygem_id" => [nil, nil], + "disabled_reason" => [nil, nil], + "disabled_at" => [nil, nil], + "last_success" => [nil, nil], + "last_failure" => [nil, nil], + "successes_since_last_failure" => [nil, 0], + "failures_since_last_success" => [nil, 0] + }, + "unchanged" => {} + } + }, + "fields" => {}, + "arguments" => {}, + "models" => [user.to_global_id.uri.to_s] + }, + "comment" => "Sufficiently detailed", + "action" => "Web hook create action" + }, as_json) + end +end diff --git a/test/unit/avo/actions/unblock_user_test.rb b/test/unit/avo/actions/unblock_user_test.rb new file mode 100644 index 00000000000..1d59f31b487 --- /dev/null +++ b/test/unit/avo/actions/unblock_user_test.rb @@ -0,0 +1,52 @@ +require "test_helper" + +class UnblockUserTest < ActiveSupport::TestCase + setup do + @user = create(:user, :blocked) + @current_user = create(:admin_github_user, :is_admin) + @resource = UserResource.new.hydrate(model: @user) + @action = UnblockUser.new(model: @user, resource: @resource, user: @current_user, view: :edit) + end + + should "unblock user" do + args = { + current_user: @current_user, + resource: @resource, + models: [@user], + fields: { + comment: "Unblocking incorrectly flagged user" + } + } + + @action.handle(**args) + + refute_predicate @user.reload, :blocked? + end + + # Avo does not have an easy and direct way to test the message & visible class attributes. + # calling the lambda directly will raise an error because Avo requires the entire app to be loaded. + + should "ask for confirmation" do + action_mock = Data.define(:record).new(record: @user) + + assert_not_nil action_mock.instance_exec(&UnblockUser.message) + end + + should "be visible" do + action_mock = Data.define(:current_user, :view, :record).new(current_user: @current_user, view: :show, record: @user) + + assert action_mock.instance_exec(&UnblockUser.visible) + end + + context "when the user is not blocked" do + setup do + @user = create(:user) + end + + should "not be visible" do + action_mock = Data.define(:current_user, :view, :record).new(current_user: @current_user, view: :show, record: @user) + + refute action_mock.instance_exec(&UnblockUser.visible) + end + end +end diff --git a/test/unit/certificate_chain_serializer_test.rb b/test/unit/certificate_chain_serializer_test.rb new file mode 100644 index 00000000000..4120971d377 --- /dev/null +++ b/test/unit/certificate_chain_serializer_test.rb @@ -0,0 +1,64 @@ +require "test_helper" + +class CertificateChainSerializerTest < ActiveSupport::TestCase + context ".load" do + setup do + @cert_chain = File.read(File.expand_path("../certs/chain.pem", __dir__)) + end + + should "return an empty array when no certificates are present" do + assert_empty CertificateChainSerializer.load("") + assert_empty CertificateChainSerializer.load(nil) + end + + should "return an array of certificates when certificates are present" do + certs = CertificateChainSerializer.load(@cert_chain) + + assert_equal 2, certs.size + assert_equal "379469669351564281569116418161349711273802", certs[0].serial.to_s + assert_equal "85078157426496920958827089468591623647", certs[1].serial.to_s + end + end + + context ".dump" do + setup do + @certs = Array.new(2) do + key = OpenSSL::PKey::RSA.new(1024) + public_key = key.public_key + + subject = "/C=FI/O=Test/OU=Test/CN=Test" + + cert = OpenSSL::X509::Certificate.new + cert.subject = cert.issuer = OpenSSL::X509::Name.parse(subject) + cert.not_before = Time.current + cert.not_after = 1.year.from_now + cert.public_key = public_key + cert.serial = 0x0 + cert.version = 2 + cert.sign(key, OpenSSL::Digest.new("SHA256")) + cert + end + end + + should "return an nil when no certificates are present" do + assert_nil CertificateChainSerializer.dump([]) + assert_nil CertificateChainSerializer.dump(nil) + end + + should "return a certificate chain string when certificates are present" do + assert_equal @certs.map(&:to_pem).join, CertificateChainSerializer.dump(@certs) + end + + should "return a certificate chain when the chain certificates are in PEMs" do + pems = @certs.map(&:to_pem) + + assert_equal pems.join, CertificateChainSerializer.dump(pems) + end + + should "strip out excessive newlines from the certificate PEMs" do + pems = @certs.map { |cert| "#{cert.to_pem}\n\n\n" } + + assert_equal @certs.map(&:to_pem).join, CertificateChainSerializer.dump(pems) + end + end +end diff --git a/test/unit/deletion_test.rb b/test/unit/deletion_test.rb deleted file mode 100644 index dfdb4690789..00000000000 --- a/test/unit/deletion_test.rb +++ /dev/null @@ -1,148 +0,0 @@ -require "test_helper" - -class DeletionTest < ActiveSupport::TestCase - setup do - @user = create(:user) - Pusher.new(@user, gem_file).process - @version = Version.last - Rubygem.__elasticsearch__.create_index! force: true - Rubygem.import - end - - should "be indexed" do - @version.indexed = false - assert Deletion.new(version: @version, user: @user).invalid?, - "Deletion should only work on indexed gems" - end - - context "association" do - subject { Deletion.new(version: @version, user: @user) } - - should belong_to :user - end - - context "with deleted gem" do - setup do - @gem_name = @version.rubygem.name - GemCachePurger.stubs(:call) - end - - context "when delete is called" do - setup do - delete_gem - end - - should "unindexes" do - refute @version.indexed? - refute @version.rubygem.indexed? - end - - should "be considered deleted" do - assert Version.yanked.include?(@version) - end - - should "no longer be latest" do - refute @version.reload.latest? - end - - should "keep the yanked time" do - assert @version.reload.yanked_at - end - - should "set the yanked info checksum" do - refute_nil @version.reload.yanked_info_checksum - end - - should "delete the .gem file" do - assert_nil RubygemFs.instance.get("gems/#{@version.full_name}.gem"), "Rubygem still exists!" - end - - should "send gem yanked email" do - Delayed::Worker.new.work_off - - email = ActionMailer::Base.deliveries.last - assert_equal "Gem #{@version.to_title} yanked from RubyGems.org", email.subject - assert_equal [@user.email], email.to - end - end - - should "call GemCachePurger" do - GemCachePurger.expects(:call).with(@gem_name) - - delete_gem - end - end - - should "enque job for updating ES index, spec index and purging cdn" do - assert_difference "Delayed::Job.count", 8 do - delete_gem - end - - Delayed::Worker.new.work_off - - response = Rubygem.__elasticsearch__.client.get index: "rubygems-#{Rails.env}", - type: "rubygem", - id: @version.rubygem_id - assert_equal true, response["_source"]["yanked"] - end - - should "record version metadata" do - deletion = Deletion.new(version: @version, user: @user) - assert_nil deletion.rubygem - deletion.valid? - assert_equal deletion.rubygem, @version.rubygem.name - end - - context "with restored gem" do - setup do - @gem_name = @version.rubygem.name - GemCachePurger.stubs(:call) - RubygemFs.instance.stubs(:restore).returns true - end - - context "when gem is deleted and restored" do - setup do - @deletion = delete_gem - @deletion.restore! - end - - should "index rubygem and version" do - assert @version.rubygem.indexed? - assert @version.indexed? - end - - should "reorder versions" do - assert @version.reload.latest? - end - - should "remove the yanked time and yanked_info_checksum" do - assert_nil @version.yanked_at - assert_nil @version.yanked_info_checksum - end - - should "purge fastly" do - Fastly.expects(:purge).with("gems/#{@version.full_name}.gem").times(2) - Fastly.expects(:purge).with("quick/Marshal.4.8/#{@version.full_name}.gemspec.rz").times(2) - - Delayed::Worker.new.work_off - end - - should "remove deletion record" do - assert @deletion.destroyed? - end - end - - should "call GemCachePurger" do - GemCachePurger.expects(:call).with(@gem_name).times(2) - - @deletion = delete_gem - @deletion.restore! - end - end - - private - - def delete_gem - Deletion.create!(version: @version, user: @user) - end -end diff --git a/test/unit/erb_implementation_test.rb b/test/unit/erb_implementation_test.rb new file mode 100644 index 00000000000..0d6a05d54c7 --- /dev/null +++ b/test/unit/erb_implementation_test.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "test_helper" + +class ErbImplementationTest < ActiveSupport::TestCase + ERB_GLOB = File.join( + "app", "views", "**", "{*.htm,*.html,*.htm.erb,*.html.erb,*.html+*.erb}" + ) + + Dir[ERB_GLOB, base: Rails.root].each do |filename| + test "html errors in #{filename}" do + data = Rails.root.join(filename).read + assert_nothing_raised { BetterHtml::BetterErb::ErubiImplementation.new(data, filename:).validate! } + end + end +end diff --git a/test/unit/erb_safety_test.rb b/test/unit/erb_safety_test.rb new file mode 100644 index 00000000000..747e94a5c86 --- /dev/null +++ b/test/unit/erb_safety_test.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "test_helper" +require "better_html/test_helper/safe_erb_tester" + +class ErbSafetyTest < ActiveSupport::TestCase + include BetterHtml::TestHelper::SafeErbTester + ERB_GLOB = File.join( + "app", "views", "**", "{*.htm,*.html,*.htm.erb,*.html.erb,*.html+*.erb}" + ) + + Dir[ERB_GLOB, base: Rails.root].each do |filename| + test "missing javascript escapes in #{filename}" do + assert_erb_safety(Rails.root.join(filename).read, filename:) + end + end +end diff --git a/test/unit/factories_test.rb b/test/unit/factories_test.rb new file mode 100644 index 00000000000..fb103e9336e --- /dev/null +++ b/test/unit/factories_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class FactoriesTest < ActiveSupport::TestCase + test "can create factories including traits" do + assert_nothing_raised { FactoryBot.lint(traits: true) } + end +end diff --git a/test/unit/fastly_test.rb b/test/unit/fastly_test.rb index 84e8ac1714c..102ad92bff2 100644 --- a/test/unit/fastly_test.rb +++ b/test/unit/fastly_test.rb @@ -3,16 +3,54 @@ class FastlyTest < ActiveSupport::TestCase setup do ENV["FASTLY_DOMAINS"] = "domain1.example.com,domain2.example.com" + ENV["FASTLY_SERVICE_ID"] = "service-id" + ENV["FASTLY_API_KEY"] = "api-key" end teardown do ENV["FASTLY_DOMAINS"] = nil + ENV["FASTLY_SERVICE_ID"] = nil + ENV["FASTLY_API_KEY"] = nil end context ".purge" do should "purge for each domain" do - RestClient::Request.expects(:execute).times(2).returns("{}") - Fastly.purge("some-url") + stub_request(:purge, "https://domain1.example.com/some-url") + .with(headers: { "Fastly-Key" => "api-key" }) + .to_return(status: 200, body: "{}") + stub_request(:purge, "https://domain2.example.com/some-url") + .with(headers: { "Fastly-Key" => "api-key" }) + .to_return(status: 200, body: "{}") + + assert Fastly.purge(path: "some-url") + end + + should "soft purge for each domain" do + stub_request(:purge, "https://domain1.example.com/some-url") + .with(headers: { "Fastly-Key" => "api-key", "Fastly-Soft-Purge" => "1" }) + .to_return(status: 200, body: "{}") + stub_request(:purge, "https://domain2.example.com/some-url") + .with(headers: { "Fastly-Key" => "api-key", "Fastly-Soft-Purge" => "1" }) + .to_return(status: 200, body: "{}") + + assert Fastly.purge(path: "some-url", soft: true) + end + end + + context ".purge_key" do + should "send a post request" do + stub_request(:post, "https://api.fastly.com/service/service-id/purge/some-key") + .with(headers: { "Fastly-Key" => "api-key" }) + .to_return(status: 200, body: "{}") + + assert Fastly.purge_key("some-key") + end + should "send a post request for soft purges" do + stub_request(:post, "https://api.fastly.com/service/service-id/purge/some-key") + .with(headers: { "Fastly-Key" => "api-key", "Fastly-Soft-Purge" => "1" }) + .to_return(status: 200, body: "{}") + + assert Fastly.purge_key("some-key", soft: true) end end end diff --git a/test/unit/gem_cache_purger_test.rb b/test/unit/gem_cache_purger_test.rb index 3b73df00b01..d73aa57f616 100644 --- a/test/unit/gem_cache_purger_test.rb +++ b/test/unit/gem_cache_purger_test.rb @@ -1,6 +1,8 @@ require "test_helper" class GemCachePurgerTest < ActiveSupport::TestCase + include ActiveJob::TestHelper + context "#call" do setup do @gem_name = "test123" @@ -9,18 +11,21 @@ class GemCachePurgerTest < ActiveSupport::TestCase should "expire API memcached" do Rails.cache.expects(:delete).with("info/#{@gem_name}") Rails.cache.expects(:delete).with("names") - Rails.cache.expects(:delete).with("deps/v1/#{@gem_name}") GemCachePurger.call(@gem_name) end should "purge cdn cache" do - Fastly.expects(:purge).with("info/#{@gem_name}", soft: true) - Fastly.expects(:purge).with("names", soft: true) - Fastly.expects(:purge).with("versions", soft: true) + # Purposely uses a hash because delayed_job lacks correct support for kwargs. + # Mocha handles kwargs correctly and complains if we expect kwargs when delay sends a hash. + # See: https://github.com/collectiveidea/delayed_job/issues/1134 + Fastly.expects(:purge).with({ path: "info/#{@gem_name}", soft: true }) + Fastly.expects(:purge).with({ path: "gem/#{@gem_name}", soft: true }) + Fastly.expects(:purge).with({ path: "names", soft: true }) + Fastly.expects(:purge).with({ path: "versions", soft: true }) GemCachePurger.call(@gem_name) - Delayed::Worker.new.work_off + perform_enqueued_jobs end end end diff --git a/test/unit/gem_dependent_test.rb b/test/unit/gem_dependent_test.rb deleted file mode 100644 index f153a41b980..00000000000 --- a/test/unit/gem_dependent_test.rb +++ /dev/null @@ -1,114 +0,0 @@ -require "test_helper" - -class GemDependentTest < ActiveSupport::TestCase - context "creating a new dependency_api" do - setup do - @gem = create(:rubygem) - @gem_dependent = GemDependent.new(@gem.name) - end - - should "have some state" do - assert @gem_dependent.respond_to?(:gem_names) - end - end - - context "no gem_names" do - should "return an ArgumentError" do - assert_raises ArgumentError do - GemDependent.new.to_a - end - end - end - - context "with gem_names" do - setup do - rack2 = create(:rubygem, name: "rack2") - create(:version, number: "0.0.1", rubygem: rack2) - end - - should "return rack2" do - result = { - name: "rack2", - number: "0.0.1", - platform: "ruby", - dependencies: [] - } - - deps = GemDependent.new(["rack2"]).to_a - result.each_pair do |k, v| - assert_equal v, deps.first[k] - end - end - - context "multiple versions" do - setup do - rack = create(:rubygem, name: "rack") - create(:version, number: "0.2.2", rubygem: rack) - create(:version, number: "0.1.2", rubygem: rack) - create(:version, number: "0.1.2", platform: "jruby", rubygem: rack) - create(:version, number: "0.1.3", rubygem: rack) - end - - should "return all versions and platform" do - result = [["0.1.3", "ruby"], ["0.1.2", "ruby"], ["0.1.2", "jruby"], ["0.2.2", "ruby"]] - - deps = GemDependent.new(["rack"]).to_a - deps.map { |x| [x[:number], x[:platform]] }.each do |dep| - assert_includes result, dep - end - end - end - - context "has dependencies" do - setup do - devise = create(:rubygem, name: "devise") - version = create(:version, number: "1.0.0", rubygem: devise) - - %w[foo bar].map do |gem_name| - create(:rubygem, name: gem_name).tap do |rubygem| - gem_dependency = Gem::Dependency.new(rubygem.name, [">= 0.0.0"]) - create(:dependency, rubygem: rubygem, version: version, gem_dependency: gem_dependency) - end - end - end - - should "return dependencies" do - expected_deps = [["foo", ">= 0.0.0"], ["bar", ">= 0.0.0"]] - - dep = GemDependent.new(["devise"]).to_a.first - assert_equal "devise", dep[:name] - assert_equal "1.0.0", dep[:number] - - expected_deps.each do |expected_dep| - assert_includes dep[:dependencies], expected_dep - end - end - end - - context "non indexed versions" do - setup do - nokogiri = create(:rubygem, name: "nokogiri") - create(:version, number: "0.0.1", rubygem: nokogiri) - create(:version, number: "0.1.1", rubygem: nokogiri, indexed: false) - end - - should "filter non indexed version" do - result = { - name: "nokogiri", - number: "0.0.1", - platform: "ruby", - dependencies: [] - } - - deps = GemDependent.new(["nokogiri"]).to_a - assert_equal [result], deps - end - end - end - - context "with gem_names which do not exist" do - should "return empty array" do - assert_equal [], GemDependent.new(["does_not_exist"]).to_a - end - end -end diff --git a/test/unit/gem_package_enumerator_test.rb b/test/unit/gem_package_enumerator_test.rb new file mode 100644 index 00000000000..3601dc30997 --- /dev/null +++ b/test/unit/gem_package_enumerator_test.rb @@ -0,0 +1,67 @@ +require "test_helper" + +class GemPackageEnumeratorTest < ActiveSupport::TestCase + setup do + @gem = gem_file("bin_and_img-0.1.0.gem") + @gem_package = Gem::Package.new(@gem) + @enum = GemPackageEnumerator.new(@gem_package) + @destination_dir = Rails.root.join("tmp", "gems", @gem_package.spec.full_name) + @gem_package.extract_files(@destination_dir.to_s) unless @destination_dir.join(@gem_package.spec.full_name).exist? + end + + teardown do + @gem&.close + end + + context "#map" do + should "enumerate all the same files as Gem::Package#contents" do + contents = @enum.map(&:full_name).to_a + + assert_equal @gem_package.contents, contents + end + + should "yield each file start to finish, in order" do + order = [] + entries = @enum.map do |entry| + order << entry.full_name + RubygemContents::Entry.from_tar_entry(entry) + end + entries.each do |entry| + order << entry.path + end + expected = @gem_package.contents.flat_map { |path| [path, path] } + + assert_equal expected, order, "should touch each file twice before moving to the next file" + end + end + + should "match file for file with the extracted gem" do + files_from_disk = {} + + @destination_dir.glob("**/*", File::FNM_DOTMATCH).each do |pathname| + next if pathname.directory? + files_from_disk[pathname.relative_path_from(@destination_dir).to_s] = pathname + end + + entries = @enum.map do |entry| + RubygemContents::Entry.from_tar_entry(entry) + end + + entries.each do |entry| + pathname = files_from_disk.delete(entry.path) + + assert pathname, "should have a corresponding file on disk for #{entry.path}" + + if pathname.symlink? + assert_predicate entry, :symlink?, "#{entry.path} should be a symlink" + assert_equal pathname.readlink.to_s, entry.linkname, "#{entry.path} should have the same linkname as the file on disk" + elsif Magic.file(pathname.to_s).start_with?("text/") + assert_equal pathname.read, entry.body, "#{entry.path} should have the same contents as the file on disk" + else + assert_equal Digest::SHA256.hexdigest(pathname.read), entry.sha256, "non text file #{entry.path} should have same sha256" + end + end + + assert_empty files_from_disk, "should have processed all files from disk" + end +end diff --git a/test/unit/hostess_test.rb b/test/unit/gemcutter/middleware/hostess_test.rb similarity index 82% rename from test/unit/hostess_test.rb rename to test/unit/gemcutter/middleware/hostess_test.rb index eae27c4f6b4..93239c1d9ee 100644 --- a/test/unit/hostess_test.rb +++ b/test/unit/gemcutter/middleware/hostess_test.rb @@ -1,15 +1,16 @@ require "test_helper" -require_relative "../../lib/middleware/hostess" +require_relative "../../../../lib/gemcutter/middleware/hostess" -class HostessTest < ActiveSupport::TestCase +class Gemcutter::Middleware::HostessTest < ActiveSupport::TestCase include Rack::Test::Methods setup do + RubygemFs.mock! create(:gem_download) end def app - Hostess.new(-> { [200, {}, ""] }) + Gemcutter::Middleware::Hostess.new(->(_) { [202, {}, "passthrough"] }) end def touch(path) @@ -35,10 +36,18 @@ def touch(path) should "serve up #{index} locally" do touch index get index + assert_equal 200, last_response.status end end + should "not capture paths that are not the gem download path" do + get "/gems/rails/versions/3.0.0/contents/rails.gemspec" + + assert_equal 202, last_response.status + assert_equal "passthrough", last_response.body + end + context "with gem" do setup do @download_count = GemDownload.total_count @@ -58,6 +67,7 @@ def touch(path) should "not be able to find bad gem" do get "/gems/rails-3.0.0.gem" + assert_equal 404, last_response.status end @@ -68,11 +78,13 @@ def touch(path) path = "/quick/Marshal.4.8/#{version.full_name}.gemspec.rz" touch path get path + assert_equal 200, last_response.status end should "not be able to find a bad gemspec" do get "/quick/Marshal.4.8/rails-3.0.0.gemspec.rz" + assert_equal 404, last_response.status end @@ -84,6 +96,7 @@ def touch(path) version = create(:version, rubygem: rubygem, number: "0.0.0") get file + assert_equal 200, last_response.status assert_equal download_count + 1, GemDownload.total_count assert_equal 1, rubygem.reload.downloads diff --git a/test/unit/redirector_test.rb b/test/unit/gemcutter/middleware/redirector_test.rb similarity index 66% rename from test/unit/redirector_test.rb rename to test/unit/gemcutter/middleware/redirector_test.rb index dfcbc21643b..ed89fbcab6b 100644 --- a/test/unit/redirector_test.rb +++ b/test/unit/gemcutter/middleware/redirector_test.rb @@ -1,18 +1,19 @@ require "test_helper" -class RedirectorTest < ActiveSupport::TestCase +class Gemcutter::Middleware::RedirectorTest < ActiveSupport::TestCase include Rack::Test::Methods def app Rack::Builder.new do - use Redirector + use Gemcutter::Middleware::Redirector run ->(_) { [200, { "Content-Type" => "text/html" }, []] } end end should "forward requests that don't match" do get "/specs.4.8.gz", {}, "HTTP_HOST" => Gemcutter::HOST - assert last_response.ok? + + assert_predicate last_response, :ok? end should "redirect requests from a non-HOST domain" do @@ -37,39 +38,21 @@ def app path = "/api/v1/gems" post path, {}, "HTTP_HOST" => "gems.rubyforge.org" - assert last_response.ok? - end - - %w[/book - /book/42 - /chapter/58 - /read/book/2 - /export - /shelf/9000 - /syndicate.xml].each do |uri| - should "redirect to docs.rubygems.org when #{uri} is hit" do - get uri, {}, "HTTP_HOST" => Gemcutter::HOST - - assert_equal 301, last_response.status - assert_equal "https://docs.rubygems.org#{uri}", last_response.headers["Location"] - end - end - - should "not redirect docs.rubygems.org to a url that redirects back to docs.rubygems.org" do - get "/read/book/2", {}, "HTTP_HOST" => "docs.rubygems.org" - - assert_equal 200, last_response.status + assert_predicate last_response, :ok? end should "allow fastly domains" do get "/", {}, "HTTP_HOST" => "index.rubygems.org" + assert_equal 200, last_response.status get "/", {}, "HTTP_HOST" => "fastly.rubygems.org" + assert_equal 200, last_response.status end should "allow healthcheck" do get "/internal/ping", {}, "HTTP_HOST" => "localhost" + assert_equal 200, last_response.status end end diff --git a/test/unit/gemcutter/request_ip_address_test.rb b/test/unit/gemcutter/request_ip_address_test.rb new file mode 100644 index 00000000000..9a7600823f4 --- /dev/null +++ b/test/unit/gemcutter/request_ip_address_test.rb @@ -0,0 +1,101 @@ +require "test_helper" + +class Gemcutter::RequestIpAddressTest < ActiveSupport::TestCase + setup do + @request = ActionDispatch::Request.new( + "REMOTE_ADDR" => "127.0.0.1" + ) + end + + should "return nil with no remote_ip" do + @request.delete_header "REMOTE_ADDR" + + assert_nil @request.ip_address + end + + should "return nil with invalid remote_ip" do + @request.headers["REMOTE_ADDR"] = "invalid" + + assert_nil @request.ip_address + end + + should "create a new ip address" do + ip_address = @request.ip_address + + refute_nil ip_address + assert_equal IPAddr.new("127.0.0.1"), ip_address.ip_address + assert_equal "12ca17b49af2289436f303e0166030a21e525d266e209267433801a8fd4071a0", ip_address.hashed_ip_address + refute_predicate ip_address, :geoip_info + end + + should "use existing ip address" do + create(:ip_address, ip_address: "127.0.0.1") + + ip_address = @request.ip_address + + refute_nil ip_address + assert_equal IPAddr.new("127.0.0.1"), ip_address.ip_address + assert_equal "12ca17b49af2289436f303e0166030a21e525d266e209267433801a8fd4071a0", ip_address.hashed_ip_address + end + + should "not record GEOIP_INFO without RUBYGEMS-PROXY-TOKEN set" do + stub_const(Gemcutter::RequestIpAddress, :PROXY_TOKEN, "abc") do + assert_nil @request.ip_address.geoip_info + end + end + + should "not record GEOIP_INFO without Gemcutter::PROXY_TOKEN set" do + @request.headers["RUBYGEMS-PROXY-TOKEN"] = "abc" + + stub_const(Gemcutter::RequestIpAddress, :PROXY_TOKEN, nil) do + assert_nil @request.ip_address.geoip_info + end + end + + should "record empty GEOIP_INFO" do + @request.headers["RUBYGEMS-PROXY-TOKEN"] = "abc" + + stub_const(Gemcutter::RequestIpAddress, :PROXY_TOKEN, "abc") do + geoip_info = @request.ip_address.geoip_info + + refute_nil geoip_info + assert_empty(geoip_info.attributes.except("id", "created_at", "updated_at").compact) + assert_predicate geoip_info, :persisted? + end + end + + should "record GEOIP_INFO" do + @request.headers["RUBYGEMS-PROXY-TOKEN"] = "abc" + + Gemcutter::RequestIpAddress::GEOIP_FIELDS.each_with_object(build(:geoip_info, :usa)) do |(field, header), info| + @request.headers[header] = info[field] + end + + stub_const(Gemcutter::RequestIpAddress, :PROXY_TOKEN, "abc") do + geoip_info = @request.ip_address.geoip_info + + refute_nil geoip_info + assert_equal( + { "continent_code" => "NA", "country_code" => "US", "country_code3" => "USA", + "country_name" => "United States of America", "region" => "NY", + "city" => "Buffalo" }, + geoip_info.attributes.except("id", "created_at", "updated_at").compact + ) + assert_predicate geoip_info, :persisted? + end + end + + should "record ignoring invalid GEOIP_INFO" do + @request.headers["RUBYGEMS-PROXY-TOKEN"] = "abc" + @request.headers["GEOIP-CONTINENT-CODE"] = "NAH" + @request.headers["GEOIP-COUNTRY-CODE3"] = "NAH" + + stub_const(Gemcutter::RequestIpAddress, :PROXY_TOKEN, "abc") do + geoip_info = @request.ip_address.geoip_info + + refute_nil geoip_info + refute_predicate geoip_info, :persisted? + assert_predicate @request.ip_address, :persisted? + end + end +end diff --git a/test/unit/gemcutter/user_agent_parser_test.rb b/test/unit/gemcutter/user_agent_parser_test.rb new file mode 100644 index 00000000000..b47889fa782 --- /dev/null +++ b/test/unit/gemcutter/user_agent_parser_test.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "test_helper" + +class Gemcutter::UserAgentParserTest < ActiveSupport::TestCase + make_my_diffs_pretty! + + test "parses a browser user agent" do + assert_parse_as "Mozilla/5.0 (Linux; Android 10; SM-A205U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.101 Mobile Safari/537.36", + { installer: "Browser", device: "Samsung SM-A205U", os: "Android", user_agent: "Chrome Mobile" } + end + + test "parses a bundler user agent" do + assert_parse_as \ + "bundler/1.12.5 rubygems/2.6.10 ruby/2.3.1 (x86_64-pc-linux-gnu) command/install options/orig_path 95ac718b0e500f41", + { installer: "Bundler", implementation: "Ruby", system: "x86_64-pc-linux-gnu" } + assert_parse_as \ + "bundler/1.12.5 rubygems/2.6.10 ruby/2.3.1 (x86_64-pc-linux-gnu) command/install jruby/9.2.4.0 options/orig_path 95ac718b0e500f41", + { installer: "Bundler", implementation: "JRuby", system: "x86_64-pc-linux-gnu" } + assert_parse_as \ + "bundler/1.12.5 rubygems/2.6.10 ruby/2.3.1 (x86_64-pc-linux-gnu) command/install truffleruby/23.1.2 options/orig_path 95ac718b0e500f41", + { installer: "Bundler", implementation: "TruffleRuby", system: "x86_64-pc-linux-gnu" } + end + + test "parses a rubygems user agent" do + assert_parse_as "RubyGems/2.7.7 x86_64-linux Ruby/1.8.7 (2012-10-12 patchlevel 371)", + { installer: "RubyGems", implementation: "Ruby", system: "x86_64-linux" } + assert_parse_as "Ruby, RubyGems/2.7.7 x86_64-linux Ruby/2.6.0dev (2018-06-15 revision 63671)", + { installer: "RubyGems", implementation: "Ruby", system: "x86_64-linux" } + assert_parse_as "Ruby, Gems 1.1.1", + { installer: "RubyGems", implementation: "Ruby" } + end + + test "parses a rubygems-oidc-action user agent" do + assert_parse_as "rubygems-oidc-action", + { installer: "RubyGems OIDC GitHub Action" } + end + + test "raises on an unknown user agent" do + refute_parse "Unknown/1.0" + refute_parse "Unknown" + end + + def refute_parse(string) + assert_raises Gemcutter::UserAgentParser::UnableToParse do + Gemcutter::UserAgentParser.call(string) + end + end + + def assert_parse_as(string, hash) + assert_equal Events::UserAgentInfo.new(**hash), Gemcutter::UserAgentParser.call(string, exclusive: true) + end +end diff --git a/test/unit/github_secret_scanning_test.rb b/test/unit/github_secret_scanning_test.rb new file mode 100644 index 00000000000..11ce649b2f1 --- /dev/null +++ b/test/unit/github_secret_scanning_test.rb @@ -0,0 +1,27 @@ +require "test_helper" + +class GitHubSecretScanningTest < ActiveSupport::TestCase + should "return false when empty json" do + stub_request(:get, GitHubSecretScanning::KEYS_URI) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: {}.to_json + ) + key = GitHubSecretScanning.new("key_id") + + refute key.valid_github_signature?("", "") + end + + should "return false if no public key is found" do + stub_request(:get, GitHubSecretScanning::KEYS_URI) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { public_keys: {} }.to_json + ) + key = GitHubSecretScanning.new("key_id") + + refute key.valid_github_signature?("", "") + end +end diff --git a/test/unit/helpers/api_keys_helper_test.rb b/test/unit/helpers/api_keys_helper_test.rb new file mode 100644 index 00000000000..f66ed603cd7 --- /dev/null +++ b/test/unit/helpers/api_keys_helper_test.rb @@ -0,0 +1,75 @@ +require "test_helper" + +class ApiKeysHelperTest < ActionView::TestCase + context "gem_scope" do + should "return gem name" do + @ownership = create(:ownership) + @api_key = create(:api_key, scopes: %i[push_rubygem], owner: @ownership.user, ownership: @ownership) + + assert_equal @ownership.rubygem.name, gem_scope(@api_key) + end + + should "return all gems if there is no scope specified" do + assert_equal "All Gems", gem_scope(create(:api_key)) + end + + should "return error tooltip if key if gem ownership is removed" do + @ownership = create(:ownership) + @api_key = create(:api_key, scopes: %i[push_rubygem], owner: @ownership.user, ownership: @ownership) + @ownership.destroy! + rubygem_name = @ownership.rubygem.name + + expected_dom = <<~HTML.squish.gsub(/>\s+<") + #{rubygem_name} [?] + HTML + + assert_equal expected_dom, gem_scope(@api_key.reload) + end + end + + def expected_checkbox(scope, exclusive: false, gem_scope: false) + data = { exclusive_checkbox_target: exclusive ? "exclusive" : "inclusive" } + data[:gem_scope_target] = "checkbox" if gem_scope + + [ + scope, + { class: "form__checkbox__input", id: scope, data: }, + "true", + "false" + ] + end + + context "api_key_checkbox" do + setup do + @f = Object.new + def @f.check_box(*args) + args + end + end + + should "return checkbox for exclusive scope" do + scope = ApiKey::EXCLUSIVE_SCOPES.first + + assert_equal expected_checkbox(scope, exclusive: true), api_key_checkbox(@f, scope) + end + + should "return checkbox for gem scope" do + scopes = ApiKey::APPLICABLE_GEM_API_SCOPES + + scopes.each do |scope| + assert_equal expected_checkbox(scope, gem_scope: true), api_key_checkbox(@f, scope) + end + end + + should "return checkbox for normal scope" do + scopes = ApiKey::API_SCOPES - ApiKey::EXCLUSIVE_SCOPES - ApiKey::APPLICABLE_GEM_API_SCOPES + + scopes.each do |scope| + assert_equal expected_checkbox(scope), api_key_checkbox(@f, scope) + end + end + end +end diff --git a/test/unit/helpers/application_helper_test.rb b/test/unit/helpers/application_helper_test.rb index 9f2d41f0dff..d139484cc6c 100644 --- a/test/unit/helpers/application_helper_test.rb +++ b/test/unit/helpers/application_helper_test.rb @@ -7,15 +7,22 @@ class ApplicationHelperTest < ActionView::TestCase end should "return with explicit title" do @title = "Sample" + assert_equal "Sample | #{t :title} | #{t :subtitle}", page_title end + should "return with explicit title for header only" do + @title_for_header_only = "Profile of john" + + assert_equal "Profile of john | #{t :title} | #{t :subtitle}", page_title + end end should "return gemcutter atom feed link" do feed_link = '' + 'title="RubyGems.org | Latest Gems">' atom_feed_link_result = atom_feed_link(t(:feed_latest), "https://feeds.feedburner.com/gemcutter-latest") + assert_equal feed_link, atom_feed_link_result end @@ -25,8 +32,17 @@ class ApplicationHelperTest < ActionView::TestCase create(:version, rubygem: rubygem, number: "3.0.0", platform: "ruby", description: text) assert_equal "alert("foo");Rails authentication & authorization", - short_info(rubygem.versions.most_recent) - assert short_info(rubygem.versions.most_recent).html_safe? + short_info(rubygem.most_recent_version) + assert_predicate short_info(rubygem.most_recent_version), :html_safe? + end + + should "use gem summary before gem description" do + desc = "this is an awesome gem that does so many wonderful things" + summary = "an awesome gem" + rubygem = create(:rubygem, name: "SomeGem") + create(:version, rubygem: rubygem, number: "3.0.0", platform: "ruby", description: desc, summary: summary) + + assert_equal "an awesome gem", short_info(rubygem.most_recent_version) end context "rubygem" do @@ -34,13 +50,76 @@ class ApplicationHelperTest < ActionView::TestCase @rubygem = create(:rubygem) @rubygem.stubs(:downloads).returns(1_000_000) end + should "downloads count with delimeter" do - assert_equal download_count(@rubygem), "1,000,000" + assert_equal("1,000,000", download_count(@rubygem)) end should "stats graph meter" do most_downloaded_count = 8_000_000 - assert_equal stats_graph_meter(@rubygem, most_downloaded_count), 12.5 + + assert_in_delta(stats_graph_meter(@rubygem, most_downloaded_count), 12.5) + end + end + + context "flash_message string with html" do + setup do + @message = "This is a test" + end + + should "sanitize with :notice_html" do + assert_instance_of ActiveSupport::SafeBuffer, flash_message(:notice_html, @message) + end + + should "not sanitize with :notice" do + assert_instance_of String, flash_message(:notice, @message) + end + end + + context "avatar" do + setup do + @user = create(:user, email: "email@example.com") + end + + should "raise when invalid theme is requested" do + assert_raises(StandardError) { avatar(160, "id", @user, theme: :unknown) } + end + + context "with publicly available email" do + setup do + @user.public_email = true + end + + should "return gravatar" do + url = avatar(160, "id", @user) + expected_uri = "/users/#{@user.id}/avatar.jpeg?size=160&theme=light" + + assert_equal "", url + end + end + + context "with publicly hidden email" do + setup do + @user.public_email = false + end + + should "return light themed default avatar" do + url = avatar(160, "id", @user, theme: :light) + + assert_equal "", url + end + + should "return light themed default avatar by default" do + url = avatar(160, "id", @user) + + assert_equal "", url + end + + should "return dark themed default avatar" do + url = avatar(160, "id", @user, theme: :dark) + + assert_equal "", url + end end end end diff --git a/test/unit/helpers/pages_helper_test.rb b/test/unit/helpers/pages_helper_test.rb index 61cb396bc23..6adce171e5d 100644 --- a/test/unit/helpers/pages_helper_test.rb +++ b/test/unit/helpers/pages_helper_test.rb @@ -19,6 +19,7 @@ class PagesHelperTest < ActionView::TestCase should "return 0.0.0 as version number if version doesn't exist" do @rubygem.versions.each(&:destroy) + assert_equal "0.0.0", version_number end @@ -29,5 +30,11 @@ class PagesHelperTest < ActionView::TestCase should "return subtitle with release date and version number if both exist" do assert_equal "v#{@version_last.number} - #{nice_date_for(@version_last.created_at)}", subtitle end + + should "return subtitle with only version number if version doesn't exist" do + @rubygem.destroy + + assert_equal "v0.0.0", subtitle + end end end diff --git a/test/unit/helpers/rubygems_helper_test.rb b/test/unit/helpers/rubygems_helper_test.rb index 908f50451bc..07df51e9f4c 100644 --- a/test/unit/helpers/rubygems_helper_test.rb +++ b/test/unit/helpers/rubygems_helper_test.rb @@ -11,13 +11,16 @@ class RubygemsHelperTest < ActionView::TestCase end should "singular if version has one license" do @version.stubs(:licenses).returns(["MIT"]) + assert_equal "License", pluralized_licenses_header(@version) end should "plural if version has no license or more than one license" do @version.stubs(:licenses) + assert_equal "Licenses", pluralized_licenses_header(@version) @version.stubs(:licenses).returns(%w[MIT GPL-2]) + assert_equal "Licenses", pluralized_licenses_header(@version) end end @@ -34,6 +37,7 @@ class RubygemsHelperTest < ActionView::TestCase should "create the directory" do directory = link_to_directory + ("A".."Z").each do |letter| assert_match rubygems_path(letter: letter), directory end @@ -43,16 +47,20 @@ class RubygemsHelperTest < ActionView::TestCase rubygem = stub rubygem.stubs(:versions_count).returns 6 rubygem.stubs(:yanked_versions?).returns false + assert show_all_versions_link?(rubygem) rubygem.stubs(:versions_count).returns 1 rubygem.stubs(:yanked_versions?).returns false + refute show_all_versions_link?(rubygem) rubygem.stubs(:yanked_versions?).returns true + assert show_all_versions_link?(rubygem) end should "show a nice formatted date" do time = Time.zone.parse("2011-03-18T00:00:00-00:00") + assert_equal "March 18, 2011", nice_date_for(time) end @@ -102,53 +110,59 @@ class RubygemsHelperTest < ActionView::TestCase end should "create links to owners gem overviews" do - users = Array.new(2) { create(:user) } + users = create_list(:user, 2) @rubygem = create(:rubygem, owners: users) expected_links = users.sort_by(&:id).map do |u| - link_to gravatar(48, "gravatar-#{u.id}", u), + link_to avatar(48, "gravatar-#{u.id}", u), profile_path(u.display_id), alt: u.display_handle, title: u.display_handle end.join + assert_equal expected_links, links_to_owners(@rubygem) - assert links_to_owners(@rubygem).html_safe? + assert_predicate links_to_owners(@rubygem), :html_safe? end should "create links to gem owners without mfa" do - with_mfa = create(:user, mfa_level: "ui_and_api") - without_mfa = create_list(:user, 2, mfa_level: "disabled") + with_mfa = create(:user) + with_mfa.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + without_mfa = create_list(:user, 2) rubygem = create(:rubygem, owners: [*without_mfa, with_mfa]) expected_links = without_mfa.sort_by(&:id).map do |u| - link_to gravatar(48, "gravatar-#{u.id}", u), + link_to avatar(48, "gravatar-#{u.id}", u), profile_path(u.display_id), alt: u.display_handle, title: u.display_handle end.join + assert_equal expected_links, links_to_owners_without_mfa(rubygem) - assert links_to_owners_without_mfa(rubygem).html_safe? + assert_predicate links_to_owners_without_mfa(rubygem), :html_safe? end end context "simple_markup" do should "sanitize copy" do text = 'Rails authentication & authorization' + assert_equal "

          alert("foo");Rails authentication & authorization

          ", simple_markup(text) - assert simple_markup(text).html_safe? + assert_predicate simple_markup(text), :html_safe? end should "work on rdoc strings" do text = "== FOO" + assert_equal "\n

          FOO

          \n", simple_markup(text) - assert simple_markup(text).html_safe? + assert_predicate simple_markup(text), :html_safe? end should "sanitize rdoc strings" do text = "== FOO\nclick[javascript:alert('foo')]" + assert_equal "\n

          FOO

          \n\n

          click

          \n", simple_markup(text) - assert simple_markup(text).html_safe? + assert_predicate simple_markup(text), :html_safe? end end @@ -193,6 +207,21 @@ class RubygemsHelperTest < ActionView::TestCase end end + context "oidc_api_key_role_links" do + should "return joined links" do + user = create(:user) + rubygem = create(:rubygem, name: "my_gem", owners: [user]) + role = create(:oidc_api_key_role, name: "Push my_gem", api_key_permissions: { gems: ["my_gem"], scopes: ["push_rubygem"] }, user: user) + stubs(:current_user).returns(user) + + role_link = link_to "OIDC: #{role.name}", profile_oidc_api_key_role_path(role.token), class: "gem__link t-list__item" + create_link = link_to "OIDC: Create", new_profile_oidc_api_key_role_path(rubygem: rubygem.name, scopes: ["push_rubygem"]), + class: "gem__link t-list__item" + + assert_equal safe_join([role_link, create_link]), oidc_api_key_role_links(rubygem) + end + end + context "change_diff_link" do context "with yanked version" do setup do diff --git a/test/unit/mailer_test.rb b/test/unit/mailer_test.rb new file mode 100644 index 00000000000..a84ce8c12e7 --- /dev/null +++ b/test/unit/mailer_test.rb @@ -0,0 +1,165 @@ +require "test_helper" + +class MailerTest < ActionMailer::TestCase + MIN_DOWNLOADS_FOR_MFA_RECOMMENDATION_POLICY = 165_000_000 + MIN_DOWNLOADS_FOR_MFA_REQUIRED_POLICY = 180_000_000 + + setup do + TOPLEVEL_BINDING.receiver.stubs(:mx_exists?).returns(true) + end + + context "sending mail for mfa recommendation announcement" do + setup do + @user = create(:user) + create(:rubygem, owners: [@user], downloads: MIN_DOWNLOADS_FOR_MFA_RECOMMENDATION_POLICY) + + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + @io_output, _error = capture_io { Rake::Task["mfa_policy:announce_recommendation"].execute } + end + end + + should "send mail to users" do + refute_empty ActionMailer::Base.deliveries + email = ActionMailer::Base.deliveries.last + + assert_equal [@user.email], email.to + assert_equal ["no-reply@mailer.rubygems.org"], email.from + assert_equal "Please enable multi-factor authentication on your RubyGems account", email.subject + assert_match "Thank you for making the RubyGems ecosystem more secure", email.text_part.body.to_s + assert_match "Sending 1 MFA announcement email", @io_output + end + end + + context "sending mail for mfa required soon announcement" do + should "send mail to users with with more than 180M+ downloads and have MFA disabled" do + user = create(:user, mfa_level: "disabled") + create(:rubygem, owners: [user], downloads: MIN_DOWNLOADS_FOR_MFA_REQUIRED_POLICY) + + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + @io_output, _error = capture_io { Rake::Task["mfa_policy:reminder_enable_mfa"].execute } + end + + refute_empty ActionMailer::Base.deliveries + email = ActionMailer::Base.deliveries.last + + assert_equal [user.email], email.to + assert_equal ["no-reply@mailer.rubygems.org"], email.from + assert_equal "[Action Required] Enable multi-factor authentication on your RubyGems account by August 15", email.subject + assert_match "Thank you for making the RubyGems ecosystem more secure", email.text_part.body.to_s + assert_match "Sending 1 MFA reminder email", @io_output + end + + should "send mail to users with with more than 180M+ downloads and have weak MFA" do + user = create(:user) + user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + + create(:rubygem, owners: [user], downloads: MIN_DOWNLOADS_FOR_MFA_REQUIRED_POLICY) + + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + @io_output, _error = capture_io { Rake::Task["mfa_policy:reminder_enable_mfa"].execute } + end + + refute_empty ActionMailer::Base.deliveries + email = ActionMailer::Base.deliveries.last + + assert_equal [user.email], email.to + assert_equal ["no-reply@mailer.rubygems.org"], email.from + assert_equal "[Action Required] Upgrade the multi-factor authentication level on your RubyGems account by August 15", email.subject + assert_match "Recently, we've announced our security-focused ambitions to the community", email.text_part.body.to_s + assert_match "Sending 1 MFA reminder email", @io_output + end + + should "not send mail to users with with more than 180M+ downloads and have strong MFA" do + user = create(:user) + user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + create(:rubygem, owners: [user], downloads: MIN_DOWNLOADS_FOR_MFA_REQUIRED_POLICY) + + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + @io_output, _error = capture_io { Rake::Task["mfa_policy:reminder_enable_mfa"].execute } + end + + assert_empty ActionMailer::Base.deliveries + assert_match "Sending 0 MFA reminder email", @io_output + end + + should "not send mail to users with with less than 180M downloads" do + user = create(:user) + create(:rubygem, owners: [user], downloads: MIN_DOWNLOADS_FOR_MFA_REQUIRED_POLICY - 1) + + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + @io_output, _error = capture_io { Rake::Task["mfa_policy:reminder_enable_mfa"].execute } + end + + assert_empty ActionMailer::Base.deliveries + assert_match "Sending 0 MFA reminder email", @io_output + end + end + + context "sending mail for mfa required on popular gems announcement" do + should "send mail to users with with more than 180M+ downloads and have MFA disabled" do + user = create(:user, mfa_level: "disabled") + create(:rubygem, owners: [user], downloads: MIN_DOWNLOADS_FOR_MFA_REQUIRED_POLICY) + + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + @io_output, _error = capture_io { Rake::Task["mfa_policy:announce_enforcement_for_popular_gems"].execute } + end + + refute_empty ActionMailer::Base.deliveries + email = ActionMailer::Base.deliveries.last + + assert_equal [user.email], email.to + assert_equal ["no-reply@mailer.rubygems.org"], email.from + assert_equal "[Action Required] Enabling multi-factor authentication is required on your RubyGems account", email.subject + assert_match "Effective today, multi-factor authentication (MFA) is required on your RubyGems account.", email.text_part.body.to_s + assert_match "Sending 1 MFA required for popular gems email", @io_output + end + + should "send mail to users with more than 180M+ downloads and have weak MFA enabled" do + user = create(:user) + user.enable_totp!(ROTP::Base32.random_base32, :ui_only) + create(:rubygem, owners: [user], downloads: MIN_DOWNLOADS_FOR_MFA_REQUIRED_POLICY) + + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + @io_output, _error = capture_io { Rake::Task["mfa_policy:announce_enforcement_for_popular_gems"].execute } + end + + refute_empty ActionMailer::Base.deliveries + email = ActionMailer::Base.deliveries.last + + assert_equal [user.email], email.to + assert_equal ["no-reply@mailer.rubygems.org"], email.from + assert_equal "[Action Required] Upgrading the multi-factor authentication level is required on your RubyGems account", email.subject + assert_match "Effective today, multi-factor authentication (MFA) is required on your RubyGems account.", email.text_part.body.to_s + assert_match "Sending 1 MFA required for popular gems email", @io_output + end + + should "not send mail to users with more than 180M+ downloads and have strong MFA enabled" do + user = create(:user) + user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + create(:rubygem, owners: [user], downloads: MIN_DOWNLOADS_FOR_MFA_REQUIRED_POLICY) + + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + @io_output, _error = capture_io { Rake::Task["mfa_policy:announce_enforcement_for_popular_gems"].execute } + end + + assert_empty ActionMailer::Base.deliveries + assert_match "Sending 0 MFA required for popular gems email", @io_output + end + + should "not send mail to users with less than 180M downloads" do + user = create(:user) + create(:rubygem, owners: [user], downloads: MIN_DOWNLOADS_FOR_MFA_REQUIRED_POLICY - 1) + + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + @io_output, _error = capture_io { Rake::Task["mfa_policy:announce_enforcement_for_popular_gems"].execute } + end + + assert_empty ActionMailer::Base.deliveries + assert_match "Sending 0 MFA required for popular gems email", @io_output + end + end + + teardown do + Rake::Task["mfa_policy:announce_recommendation"].reenable + end +end diff --git a/test/unit/ownership_requests_mailer_test.rb b/test/unit/ownership_requests_mailer_test.rb new file mode 100644 index 00000000000..a57bff8afe5 --- /dev/null +++ b/test/unit/ownership_requests_mailer_test.rb @@ -0,0 +1,28 @@ +require "test_helper" + +class OwnershipRequestMailerTest < ActiveSupport::TestCase + include ActiveJob::TestHelper + + context "sending mail for ownership request" do + setup do + @ownership = create(:ownership) + create(:ownership_request, rubygem: @ownership.rubygem, created_at: 1.hour.ago) + Rake::Task["ownership_request_notification:send"].invoke + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob + end + + should "send mail to owners" do + refute_empty ActionMailer::Base.deliveries + email = ActionMailer::Base.deliveries.last + + assert_equal [@ownership.user.email], email.to + assert_equal ["no-reply@mailer.rubygems.org"], email.from + assert_equal "New ownership request(s) for #{@ownership.rubygem.name}", email.subject + assert_match "1 new ownership requests", email.body.to_s + end + end + + teardown do + Rake::Task["ownership_request_notification:send"].reenable + end +end diff --git a/test/unit/patterns_test.rb b/test/unit/patterns_test.rb new file mode 100644 index 00000000000..b8cbf99782d --- /dev/null +++ b/test/unit/patterns_test.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "test_helper" + +class PatternsTest < ActiveSupport::TestCase + test "ROUTE_PATTERN is linear" do + assert Regexp.linear_time?(Patterns::ROUTE_PATTERN) + end + + test "LAZY_ROUTE_PATTERN is linear" do + assert Regexp.linear_time?(Patterns::LAZY_ROUTE_PATTERN) + end + + test "NAME_PATTERN is linear" do + assert Regexp.linear_time?(Patterns::NAME_PATTERN) + end + + test "URL_VALIDATION_REGEXP is linear" do + skip "regexp is not linear" + + assert Regexp.linear_time?(Patterns::URL_VALIDATION_REGEXP) + end + + test "VERSION_PATTERN is linear" do + skip "regexp is not linear" + + assert Regexp.linear_time?(Patterns::VERSION_PATTERN) + end + + test "REQUIREMENT_PATTERN is linear" do + skip "regexp is not linear" + + assert Regexp.linear_time?(Patterns::REQUIREMENT_PATTERN) + end + + test "LETTER_REGEXP is linear" do + assert Regexp.linear_time?(Patterns::LETTER_REGEXP) + end + + test "SPECIAL_CHAR_PREFIX_REGEXP is linear" do + assert Regexp.linear_time?(Patterns::SPECIAL_CHAR_PREFIX_REGEXP) + end + + test "BASE64_SHA256_PATTERN is linear" do + assert Regexp.linear_time?(Patterns::BASE64_SHA256_PATTERN) + end +end diff --git a/test/unit/pusher_test.rb b/test/unit/pusher_test.rb deleted file mode 100644 index 9826fb34c2c..00000000000 --- a/test/unit/pusher_test.rb +++ /dev/null @@ -1,398 +0,0 @@ -require "test_helper" - -class PusherTest < ActiveSupport::TestCase - setup do - @user = create(:user, email: "user@example.com") - @gem = gem_file - @cutter = Pusher.new(@user, @gem) - end - - context "creating a new gemcutter" do - should "have some state" do - assert @cutter.respond_to?(:user) - assert @cutter.respond_to?(:version) - assert @cutter.respond_to?(:version_id) - assert @cutter.respond_to?(:spec) - assert @cutter.respond_to?(:message) - assert @cutter.respond_to?(:code) - assert @cutter.respond_to?(:rubygem) - assert @cutter.respond_to?(:body) - - assert_equal @user, @cutter.user - end - - should "initialize size from the gem" do - assert_equal @gem.size, @cutter.size - end - - context "processing incoming gems" do - should "work normally when things go well" do - @cutter.stubs(:pull_spec).returns true - @cutter.stubs(:find).returns true - @cutter.stubs(:authorize).returns true - @cutter.stubs(:validate).returns true - @cutter.stubs(:save) - - @cutter.process - end - - should "not attempt to find rubygem if spec can't be pulled" do - @cutter.stubs(:pull_spec).returns false - @cutter.stubs(:find).never - @cutter.stubs(:authorize).never - @cutter.stubs(:save).never - @cutter.process - end - - should "not attempt to authorize if not found" do - @cutter.stubs(:pull_spec).returns true - @cutter.stubs(:find) - @cutter.stubs(:authorize).never - @cutter.stubs(:save).never - - @cutter.process - end - - should "not attempt to validate if not authorized" do - @cutter.stubs(:pull_spec).returns true - @cutter.stubs(:find).returns true - @cutter.stubs(:authorize).returns false - @cutter.stubs(:validate).never - @cutter.stubs(:save).never - - @cutter.process - end - - should "not attempt to save if not validated" do - @cutter.stubs(:pull_spec).returns true - @cutter.stubs(:find).returns true - @cutter.stubs(:authorize).returns true - @cutter.stubs(:validate).returns false - @cutter.stubs(:save).never - - @cutter.process - end - end - - should "not be able to pull spec from a bad path" do - @cutter.stubs(:body).stubs(:stub!).stubs(:read) - @cutter.pull_spec - assert_nil @cutter.spec - assert_match(/RubyGems\.org cannot process this gem/, @cutter.message) - assert_equal @cutter.code, 422 - end - - should "not be able to pull spec with metadata containing bad ruby objects" do - @gem = gem_file("exploit.gem") - @cutter = Pusher.new(@user, @gem) - @cutter.pull_spec - assert_nil @cutter.spec - assert_match(/RubyGems\.org cannot process this gem/, @cutter.message) - assert_match(/ActionController::Routing::RouteSet::NamedRouteCollection/, @cutter.message) - assert_equal @cutter.code, 422 - end - - should "not be able to save a gem if it is not valid" do - legit_gem = create(:rubygem, name: "legit-gem") - create(:version, rubygem: legit_gem, number: "0.0.1") - @gem = gem_file("legit-gem-0.0.1.gem.fake") - @cutter = Pusher.new(@user, @gem) - @cutter.stubs(:save).never - @cutter.process - assert_equal @cutter.rubygem.name, "legit" - assert_equal @cutter.version.number, "gem-0.0.1" - assert_match(/There was a problem saving your gem: Number is invalid/, @cutter.message) - assert_equal @cutter.code, 403 - end - - should "not be able to save a gem if the date is not valid" do - @gem = gem_file("bad-date-1.0.0.gem") - @cutter = Pusher.new(@user, @gem) - @cutter.process - assert_match(/exception while verifying: mon out of range/, @cutter.message) - assert_equal @cutter.code, 422 - end - - should "not be able to pull spec with metadata containing bad ruby symbols" do - ["1.0.0", "2.0.0", "3.0.0", "4.0.0"].each do |version| - @gem = gem_file("dos-#{version}.gem") - @cutter = Pusher.new(@user, @gem) - @cutter.pull_spec - assert_nil @cutter.spec - assert_includes @cutter.message, %(RubyGems.org cannot process this gem) - assert_includes @cutter.message, %(Tried to load unspecified class: Symbol) - assert_equal @cutter.code, 422 - end - end - - should "be able to pull spec with metadata containing aliases" do - @gem = gem_file("aliases-0.0.0.gem") - @cutter = Pusher.new(@user, @gem) - @cutter.pull_spec - assert_not_nil @cutter.spec - assert_not_nil @cutter.spec.dependencies.first.requirement - end - - should "not be able to pull spec when no data available" do - @gem = gem_file("aliases-nodata-0.0.1.gem") - @cutter = Pusher.new(@user, @gem) - @cutter.pull_spec - assert_includes @cutter.message, %{package content (data.tar.gz) is missing} - end - end - - context "initialize new gem with find if one does not exist" do - setup do - spec = mock - spec.expects(:name).returns "some name" - spec.expects(:version).times(2).returns Gem::Version.new("1.3.3.7") - spec.expects(:original_platform).returns "ruby" - @cutter.stubs(:spec).returns spec - @cutter.stubs(:size).returns 5 - @cutter.stubs(:body).returns StringIO.new("dummy body") - - @cutter.find - end - - should "set rubygem" do - assert_equal "some name", @cutter.rubygem.name - end - - should "set version" do - assert_equal "1.3.3.7", @cutter.version.number - end - - should "set gem version size" do - assert_equal 5, @cutter.version.size - end - - should "set sha256" do - expected_sha = Digest::SHA2.base64digest(@cutter.body.string) - assert_equal expected_sha, @cutter.version.sha256 - end - end - - context "finding an existing gem" do - should "bring up existing gem with matching spec" do - @rubygem = create(:rubygem) - spec = mock - spec.stubs(:name).returns @rubygem.name - spec.stubs(:version).returns Gem::Version.new("1.3.3.7") - spec.stubs(:original_platform).returns "ruby" - @cutter.stubs(:spec).returns spec - @cutter.find - - assert_equal @rubygem, @cutter.rubygem - assert_not_nil @cutter.version - end - - should "error out when changing case with usuable versions" do - @rubygem = create(:rubygem) - create(:version, rubygem: @rubygem) - - assert_not_equal @rubygem.name, @rubygem.name.upcase - - spec = mock - spec.expects(:name).returns @rubygem.name.upcase - spec.expects(:version).returns Gem::Version.new("1.3.3.7") - spec.expects(:original_platform).returns "ruby" - @cutter.stubs(:spec).returns spec - refute @cutter.find - - assert_match(/Unable to change case/, @cutter.message) - end - - should "update the DB to reflect the case in the spec" do - @rubygem = create(:rubygem) - assert_not_equal @rubygem.name, @rubygem.name.upcase - - spec = mock - spec.stubs(:name).returns @rubygem.name.upcase - spec.stubs(:version).returns Gem::Version.new("1.3.3.7") - spec.stubs(:original_platform).returns "ruby" - @cutter.stubs(:spec).returns spec - @cutter.find - - @cutter.rubygem.save - @rubygem.reload - - assert_equal @rubygem.name, @rubygem.name.upcase - end - end - - context "checking if the rubygem can be pushed to" do - should "be true if rubygem is new" do - @cutter.stubs(:rubygem).returns Rubygem.new - assert @cutter.authorize - end - - context "with a existing rubygem" do - setup do - @rubygem = create(:rubygem, name: "the_gem_name") - @cutter.stubs(:rubygem).returns @rubygem - end - - should "be true if owned by the user" do - create(:ownership, rubygem: @rubygem, user: @user) - assert @cutter.authorize - end - - should "be true if no versions exist since it's a dependency" do - assert @cutter.authorize - end - - should "be false if not owned by user and an indexed version exists" do - create(:version, rubygem: @rubygem, number: "0.1.1") - refute @cutter.authorize - assert_equal "You do not have permission to push to this gem. Ask an owner to add you with: gem owner the_gem_name --add user@example.com", - @cutter.message - assert_equal 403, @cutter.code - end - - should "be true if not owned by user but no indexed versions exist" do - create(:version, rubygem: @rubygem, number: "0.1.1", indexed: false) - assert @cutter.authorize - end - end - end - - context "successfully saving a gemcutter" do - setup do - @rubygem = create(:rubygem, name: "gemsgemsgems") - @cutter.stubs(:rubygem).returns @rubygem - create(:version, rubygem: @rubygem, number: "0.1.1", summary: "old summary") - @spec = mock - @cutter.stubs(:version).returns @rubygem.versions[0] - @cutter.stubs(:spec).returns(@spec) - @rubygem.stubs(:update_attributes_from_gem_specification!) - Indexer.any_instance.stubs(:write_gem) - end - - context "when cutter is saved" do - setup do - assert_equal true, @cutter.save - end - - should "set gem file size" do - assert_equal @gem.size, @cutter.size - end - - should "set success code" do - assert_equal 200, @cutter.code - end - - should "set info_checksum" do - assert_not_nil @rubygem.versions.last.info_checksum - end - - should "indexe rubygem and version" do - assert @rubygem.indexed? - assert @rubygem.versions.last.indexed? - end - - should "create rubygem index" do - @rubygem.update_column("updated_at", Date.new(2016, 07, 04)) - Delayed::Worker.new.work_off - response = Rubygem.__elasticsearch__.client.get index: "rubygems-#{Rails.env}", - type: "rubygem", - id: @rubygem.id - expected_response = { - "name" => "gemsgemsgems", - "downloads" => 0, - "version" => "0.1.1", - "version_downloads" => 0, - "platform" => "ruby", - "authors" => "Joe User", - "info" => "Some awesome gem", - "licenses" => "MIT", - "metadata" => { "foo" => "bar" }, - "sha" => "b5d4045c3f466fa91fe2cc6abe79232a1a57cdf104f7a26e716e0a1e2789df78", - "project_uri" => "http://localhost/gems/gemsgemsgems", - "gem_uri" => "http://localhost/gems/gemsgemsgems-0.1.1.gem", - "homepage_uri" => "http://example.com", - "wiki_uri" => "http://example.com", - "documentation_uri" => "http://example.com", - "mailing_list_uri" => "http://example.com", - "source_code_uri" => "http://example.com", - "bug_tracker_uri" => "http://example.com", - "changelog_uri" => nil, - "funding_uri" => nil, - "yanked" => false, - "summary" => "old summary", - "description" => "Some awesome gem", - "updated" => "2016-07-04T00:00:00.000Z", - "dependencies" => { "development" => [], "runtime" => [] } - } - - assert_equal expected_response, response["_source"] - end - end - - should "purge gem cache" do - GemCachePurger.expects(:call).with(@rubygem.name).at_least_once - @cutter.save - end - - should "update rubygem attributes when saved" do - @rubygem.expects(:update_attributes_from_gem_specification!).with(@cutter.version, @spec) - @cutter.save - end - - should "enqueue job for email, updating ES index, spec index and purging cdn" do - assert_difference "Delayed::Job.count", 6 do - @cutter.save - end - end - end - - context "pushing a new version" do - setup do - @rubygem = create(:rubygem) - @cutter.stubs(:rubygem).returns @rubygem - create(:version, rubygem: @rubygem, summary: "old summary") - @version = create(:version, rubygem: @rubygem, summary: "new summary") - @cutter.stubs(:version).returns @version - @rubygem.stubs(:update_attributes_from_gem_specification!) - @cutter.stubs(:version).returns @version - GemCachePurger.stubs(:call) - Indexer.any_instance.stubs(:write_gem) - @cutter.save - end - - should "update rubygem index" do - Delayed::Worker.new.work_off - response = Rubygem.__elasticsearch__.client.get index: "rubygems-#{Rails.env}", - type: "rubygem", - id: @rubygem.id - assert_equal "new summary", response["_source"]["summary"] - end - - should "send gem pushed email" do - Delayed::Worker.new.work_off - - email = ActionMailer::Base.deliveries.last - assert_equal "Gem #{@version.to_title} pushed to RubyGems.org", email.subject - assert_equal [@user.email], email.to - end - end - - context "pushing to s3 fails" do - setup do - @gem = gem_file("test-1.0.0.gem") - @cutter = Pusher.new(@user, @gem) - @fs = RubygemFs.s3!("https://some.host") - s3_exception = Aws::S3::Errors::ServiceError.new("stub raises", "something went wrong") - Aws::S3::Client.any_instance.stubs(:put_object).with(any_parameters).raises(s3_exception) - @cutter.process - end - - should "not create version" do - rubygem = Rubygem.find_by(name: "test") - expected_message = "There was a problem saving your gem. Please try again." - assert_equal expected_message, @cutter.message - assert_equal 0, rubygem.versions.count - end - - teardown { RubygemFs.mock! } - end -end diff --git a/test/unit/rstuf/client_test.rb b/test/unit/rstuf/client_test.rb new file mode 100644 index 00000000000..616b20374d9 --- /dev/null +++ b/test/unit/rstuf/client_test.rb @@ -0,0 +1,79 @@ +require "test_helper" + +class Rstuf::ClientTest < ActiveSupport::TestCase + setup do + setup_rstuf + end + + teardown do + teardown_rstuf + end + + test "post_artifacts should post artifacts and return task_id on success" do + task_id = "12345" + stub_request(:post, "#{Rstuf.base_url}/api/v1/artifacts/") + .with(body: { artifacts: %w[artifact1 artifact2] }) + .to_return(body: { data: { task_id: task_id } }.to_json, status: 200, headers: { "Content-Type" => "application/json" }) + + response_task_id = Rstuf::Client.post_artifacts(%w[artifact1 artifact2]) + + assert_equal task_id, response_task_id + end + + test "post_artifacts should raise Error on failure" do + error_message = "Invalid artifacts" + stub_request(:post, "#{Rstuf.base_url}/api/v1/artifacts/") + .with(body: { artifacts: %w[artifact1 artifact2] }) + .to_return(body: { error: error_message }.to_json, status: 400, headers: { "Content-Type" => "application/json" }) + + assert_raises(Rstuf::Client::Error) do + Rstuf::Client.post_artifacts(%w[artifact1 artifact2]) + end + end + + test "delete_artifacts should post artifacts for deletion and return task_id on success" do + task_id = "67890" + stub_request(:post, "#{Rstuf.base_url}/api/v1/artifacts/delete") + .with(body: { artifacts: %w[artifact1 artifact2] }) + .to_return(body: { data: { task_id: task_id } }.to_json, status: 200, headers: { "Content-Type" => "application/json" }) + + response_task_id = Rstuf::Client.delete_artifacts(%w[artifact1 artifact2]) + + assert_equal task_id, response_task_id + end + + test "delete_artifacts should raise Error on failure" do + error_message = "Could not delete" + stub_request(:post, "#{Rstuf.base_url}/api/v1/artifacts/delete") + .with(body: { artifacts: %w[artifact1 artifact2] }) + .to_return(body: { error: error_message }.to_json, status: 400, headers: { "Content-Type" => "application/json" }) + + assert_raises(Rstuf::Client::Error) do + Rstuf::Client.delete_artifacts(%w[artifact1 artifact2]) + end + end + + test "task_state should return the status of the task" do + task_id = "12345" + state = "processing" + stub_request(:get, "#{Rstuf.base_url}/api/v1/task/") + .with(query: { task_id: task_id }) + .to_return(body: { data: { state: state } }.to_json, status: 200, headers: { "Content-Type" => "application/json" }) + + status = Rstuf::Client.task_state(task_id) + + assert_equal state, status + end + + test "task_state should raise Error if task retrieval fails" do + task_id = "12345" + error_message = "Task not found" + stub_request(:get, "#{Rstuf.base_url}/api/v1/task/") + .with(query: { task_id: task_id }) + .to_return(body: { error: error_message }.to_json, status: 404, headers: { "Content-Type" => "application/json" }) + + assert_raises(Rstuf::Client::Error) do + Rstuf::Client.task_state(task_id) + end + end +end diff --git a/test/unit/rstuf_test.rb b/test/unit/rstuf_test.rb new file mode 100644 index 00000000000..5f9e78ffc7d --- /dev/null +++ b/test/unit/rstuf_test.rb @@ -0,0 +1,49 @@ +require "test_helper" + +class RstufTest < ActiveSupport::TestCase + def setup + @original_base_url = Rstuf.base_url + @original_enabled = Rstuf.enabled + @original_wait_for = Rstuf.wait_for + end + + def teardown + Rstuf.base_url = @original_base_url + Rstuf.enabled = @original_enabled + Rstuf.wait_for = @original_wait_for + end + + test "default values are set correctly" do + refute Rstuf.enabled + assert_equal 1, Rstuf.wait_for + end + + test "base_url can be set and retrieved" do + new_url = "http://example.com" + Rstuf.base_url = new_url + + assert_equal new_url, Rstuf.base_url + end + + test "enabled can be set and retrieved" do + Rstuf.enabled = true + + assert Rstuf.enabled + end + + test "enabled? returns the value of enabled" do + Rstuf.enabled = false + + refute_predicate Rstuf, :enabled? + Rstuf.enabled = true + + assert_predicate Rstuf, :enabled? + end + + test "wait_for can be set and retrieved" do + new_wait = 5 + Rstuf.wait_for = new_wait + + assert_equal new_wait, Rstuf.wait_for + end +end diff --git a/test/unit/rubygem_fs_test.rb b/test/unit/rubygem_fs_test.rb index 792de1d9388..bcafa2fb508 100644 --- a/test/unit/rubygem_fs_test.rb +++ b/test/unit/rubygem_fs_test.rb @@ -1,23 +1,226 @@ +# frozen_string_literal: true + require "test_helper" class RubygemFsTest < ActiveSupport::TestCase + context "#instance" do + setup do + RubygemFs.instance_variable_set(:@fs, nil) + end + + teardown do + RubygemFs.mock! + end + + should "return S3 by default in Production" do + ENV["RAILS_ENV"] = "production" + + assert_kind_of RubygemFs::S3, RubygemFs.instance + ensure + ENV["RAILS_ENV"] = "test" + end + + should "return the default bucket instance" do + assert_equal Gemcutter.config.s3_bucket, RubygemFs.instance.bucket + end + + should "return the contents bucket instance" do + assert_equal Gemcutter.config.s3_contents_bucket, RubygemFs.contents.bucket + end + + should "return the same instance each time" do + assert_equal RubygemFs.instance, RubygemFs.instance + assert_equal RubygemFs.contents, RubygemFs.contents + end + end + + context "#mock!" do + should "set instance to a Local mock" do + assert_kind_of RubygemFs::Local, RubygemFs.mock! + end + + should "return mock for contents as well" do + RubygemFs.mock! + + assert_kind_of RubygemFs::Local, RubygemFs.contents + end + end + context "s3 filesystem" do + setup do + @fs = RubygemFs::S3.new + @s3 = Aws::S3::Client.new(stub_responses: true) + @fs.instance_variable_set(:@s3, @s3) + end + should "use default bucket when not passing as an argument" do fs = RubygemFs::S3.new + assert_equal "test.s3.rubygems.org", fs.bucket end should "use bucket passed" do fs = RubygemFs::S3.new(bucket: "foo.com") + assert_equal "foo.com", fs.bucket end + context "#in_bucket" do + should "return a new instance with the bucket set" do + foo_fs = @fs.in_bucket("foo.com") + + refute_equal @fs, foo_fs + assert_equal "foo.com", foo_fs.bucket + end + end + + context "#bucket" do + should "return the S3 bucket" do + assert_equal Gemcutter.config.s3_bucket, @fs.bucket + end + end + should "use a custom config when passed" do fs = RubygemFs::S3.new(access_key_id: "foo", secret_access_key: "bar") - def fs.s3 + def fs.s3_config [@config[:access_key_id], @config[:secret_access_key]] end - assert_equal %w[foo bar], fs.s3 + + assert_equal %w[foo bar], fs.s3_config + end + + context "#store" do + should "store the file in the bucket" do + bucket = key = body = nil + @s3.stub_responses(:put_object, lambda { |context| + bucket, key, body = context.params.values_at(:bucket, :key, :body) + {} + }) + + assert @fs.store("foo", "hello world") + assert_equal "foo", key + assert_equal "hello world", body + assert_equal "test.s3.rubygems.org", bucket + end + end + + context "#head" do + should "return nil when file doesnt exist" do + @s3.stub_responses(:head_object, ->(_) { "NoSuchKey" }) + + assert_nil @fs.head("foo") + assert_nil @fs.head("foo/bar/baz") + end + + should "return s3 response Hash" do + metadata = { "size" => "123", "path" => "foo" } + @s3.stub_responses(:head_object, ->(_) { { content_type: "text/plain", metadata: metadata } }) + + response = @fs.head("paths/foo") + + assert_equal "text/plain", response[:content_type] + assert_equal metadata, response[:metadata] + end + end + + context "#get" do + should "return nil when file doesnt exist" do + @s3.stub_responses(:get_object, ->(_) { "NoSuchKey" }) + + assert_nil @fs.get("foo") + assert_nil @fs.get("foo/bar/baz") + end + + should "return the file" do + @s3.stub_responses(:get_object, ->(_) { { body: "123" } }) + + assert_equal "123", @fs.get("foo") + end + end + + context "#each_key" do + should "lists keys without prefix" do + @s3.stub_responses(:list_objects_v2, ->(_) { { contents: [{ key: "foo" }, { key: "bar" }] } }) + + keys = [] + @fs.each_key { |key| keys << key } + + assert_equal %w[bar foo], keys.sort + end + + should "lists keys with prefix" do + prefix = nil + @s3.stub_responses(:list_objects_v2, lambda { |context| + prefix = context.params[:prefix] + { contents: [{ key: "bar" }, { key: "baz" }], prefix: prefix } + }) + + assert_equal %w[bar baz], @fs.each_key(prefix: "ba").sort + assert_equal "ba", prefix + end + end + + context "#remove" do + should "removes a single file that exists" do + keys = nil + @s3.stub_responses(:delete_objects, lambda { |context| + keys = context.params[:delete][:objects].pluck(:key) + {} + }) + + assert_empty @fs.remove("foo") + assert_equal ["foo"], keys + end + + should "removes files that exists" do + keys = nil + @s3.stub_responses(:delete_objects, lambda { |context| + keys = context.params[:delete][:objects].pluck(:key) + {} + }) + + assert_empty @fs.remove(%w[foo bar]) + assert_equal %w[foo bar], keys + end + + should "returns keys for which delete did not work" do + @s3.stub_responses(:delete_objects, lambda { |_| + { errors: [{ key: "missing" }] } + }) + + assert_equal ["missing"], @fs.remove(%w[foo missing]) + end + end + + context "#restore" do + should "restore a file that does not exist" do + @s3.stub_responses(:head_object, { + status_code: 404, + body: "", + headers: { + "x-amz-version-id" => "abcde" + } + }) + @s3.stub_responses(:delete_object, ->(r) { r.params.slice(:version_id) if r.params[:version_id] == "abcde" }) + + assert_equal({ version_id: "abcde" }, @fs.restore("foo").to_h) + end + + should "call delete when there is no version for a file that does not exist" do + @s3.stub_responses(:head_object, { + status_code: 404, + body: "", + headers: {} + }) + + assert_equal({ delete_marker: false, version_id: "ObjectVersionId", request_charged: "RequestCharged" }, @fs.restore("foo").to_h) + end + + should "do nothing when the file exists" do + @s3.stub_responses(:head_object, ->(_) { {} }) + + assert_empty(@fs.restore("foo").metadata) + end end end @@ -26,15 +229,98 @@ def fs.s3 @fs = RubygemFs::Local.new(Dir.mktmpdir) end + context "#initialize" do + should "convert the path to an absolute path" do + fs = RubygemFs::Local.new("dir") + + assert_equal File.expand_path("dir"), fs.base_dir.to_s + end + + should "default to RAILS_ROOT/server" do + fs = RubygemFs::Local.new + + assert_equal Rails.root.join("server"), fs.base_dir + assert_equal "server", fs.bucket + end + end + + context "#in_bucket" do + should "return a new instance with the bucket set" do + foo_fs = @fs.in_bucket "dir" + + refute_equal @fs, foo_fs + assert_equal "dir", foo_fs.bucket + end + end + + context "#bucket" do + should "return the basename of the base_dir to imitate S3" do + fs = @fs.in_bucket "dir" + + assert_equal "dir", fs.bucket + end + end + context "#get" do should "return nil when file doesnt exist" do - assert_nil @fs.get "foo" + assert_nil @fs.get "missing" + end + + should "return nil when the file requested is a directory" do + @fs.store "dir/foo", "123" + + assert_nil @fs.get "dir" end - should "return get the file" do - @fs.store "foo", 123 + should "get the file" do + @fs.store "foo", "123" + assert_equal "123", @fs.get("foo") end + + should "get a binary file lacking metadata" do + gem_data = gem_file(&:read) + @fs.store "gems/test.gem", gem_data + + assert_equal gem_data, @fs.get("gems/test.gem") + assert_equal Encoding::BINARY, @fs.get("gems/test.gem").encoding + end + + should "should discover encoding using Magic when the file has incomplete content type without charset" do + body = "emoji 😁" + @fs.store "file", body, content_type: "text/plain" + + assert_equal body, @fs.get("file") + assert_equal Encoding::UTF_8, @fs.get("file").encoding + end + end + + context "#head" do + should "return nil when file doesnt exist" do + assert_nil @fs.head "missing" + end + + should "return nil when the file requested is a directory" do + @fs.store "dir/foo", "123" + + assert_nil @fs.head "dir" + end + + should "return blank metadata for a file stored without any metadata" do + @fs.store "nometadata", "123" + response = @fs.head "nometadata" + + assert_equal "nometadata", response[:key] + assert_empty response[:metadata] + end + + should "return metadata for a file stored with metadata" do + @fs.store "foo", "123", metadata: { "foo" => "bar" } + response = @fs.head "foo" + + assert_equal "foo", response[:key] + assert_equal "bar", response[:metadata]["foo"] + end end context "#store" do @@ -47,19 +333,104 @@ def fs.s3 assert @fs.store "gems/foo.gem", "hello w." assert_equal "hello w.", @fs.get("gems/foo.gem") end + + should "ignore leading slashes on key names" do + assert @fs.store "/latest_specs.4.8.gz", "" # example from hostess_test.rb + assert_equal "", @fs.get("/latest_specs.4.8.gz") + end + + should "work with metadata keyword arguments" do + assert @fs.store "with/metadata", "info", metadata: { "foo" => "bar" }, content_type: "text/plain" + + assert_equal "info", @fs.get("with/metadata") + + response = @fs.head("with/metadata") + + assert_equal "with/metadata", response[:key] + assert_equal "text/plain", response[:content_type] + assert_equal({ "foo" => "bar" }, response[:metadata]) + end + end + + context "#each_key" do + setup do + @fs.store "foo", "" + @fs.store "bar/foo", "" + @fs.store "bar/baz/foo", "" + @fs.store "barbeque", "" + @fs.store "baz", "" + @fs.store ".hidden", "", metadata: { "foo" => "bar" } # ensure _metadata is not returned + end + + should "yield all keys when a block is given" do + keys = [] + @fs.each_key { |key| keys << key } + + assert_equal %w[.hidden bar/baz/foo bar/foo barbeque baz foo], keys.sort + end + + should "return an enumerable when no block is given" do + assert_equal %w[.hidden bar/baz/foo bar/foo barbeque baz foo], @fs.each_key.sort + end + + should "list all keys with prefix" do + assert_equal %w[bar/baz/foo bar/foo], @fs.each_key(prefix: "bar/").sort + end + + should "raise InvalidPathError for prefix not ending in /" do + assert_raises(RubygemFs::Local::InvalidPathError) { @fs.each_key(prefix: "bar").to_a } + end end context "#remove" do setup do - @fs.store "foo", 123 + @fs.store "foo", "123" end should "remove the file" do - assert @fs.remove "foo" + assert_empty @fs.remove("foo") + end + + should "return failing key when file doesnt exist" do + assert_equal ["bar"], @fs.remove("bar") + end + + should "not remove anything that causes problems on the filesystem" do + assert_raise(RubygemFs::Local::InvalidPathError, "blank key") { @fs.remove("") } + assert_raise(RubygemFs::Local::InvalidPathError, "root") { @fs.remove("/") } + assert_raise(RubygemFs::Local::InvalidPathError, "pwd") { @fs.remove(".") } + assert_raise(RubygemFs::Local::InvalidPathError, "parent dir") { @fs.remove("..") } + end + + should "remove empty base folders when removing nested key" do + @fs.store "bar/baz/foo/file", "123" + @fs.store "bar/directory_not_empty", "don't remove me" + + assert_raise(Errno::EISDIR) do + # s3 imitiation limitiation is files overlapping dirs + @fs.store "bar/baz", "this is a directory!" + end + + assert_empty @fs.remove("bar/baz/foo/file") + + @fs.store "bar/baz", "not a directory anymore" + + assert_equal "don't remove me", @fs.get("bar/directory_not_empty") + end + + should "removes multiple files" do + @fs.store "bar", "123" + @fs.store "baz", "123" + + assert_empty @fs.remove("foo", "bar") + refute @fs.get("foo") + refute @fs.get("bar") + assert @fs.get("baz") end - should "return false when file doesnt exist" do - refute @fs.remove "bar" + should "returns a list of errors on partial failure" do + assert_equal ["baz"], @fs.remove(%w[foo baz]) + refute @fs.get("foo") end end end diff --git a/test/unit/seeds_test.rb b/test/unit/seeds_test.rb new file mode 100644 index 00000000000..1213c08ec62 --- /dev/null +++ b/test/unit/seeds_test.rb @@ -0,0 +1,23 @@ +require "test_helper" + +class SeedsTest < ActiveSupport::TestCase + make_my_diffs_pretty! + + def all_records + ApplicationRecord.descendants.reject(&:abstract_class?).sort_by(&:name).to_h do |record_class| + [record_class.name, record_class.order(:id).map(&:attributes).as_json] + end + end + + def load_seed + capture_io { Rails.application.load_seed } + end + + test "can load seeds idempotently" do + load_seed + + assert_no_changes "all_records" do + load_seed + end + end +end diff --git a/test/unit/shasum_format_test.rb b/test/unit/shasum_format_test.rb new file mode 100644 index 00000000000..cf3d8728723 --- /dev/null +++ b/test/unit/shasum_format_test.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "test_helper" + +class ShasumFormatTest < ActiveSupport::TestCase + FILE = <<~SHASUM + 3e8179ae8e3354a22ad6c9f08a81ac2e7e39cf7b65c7dd97895cb23bf22cd48c MIT-LICENSE + 70bf070e16ea64e6b9f4d90ec5dd3380a645b4bcdcfe2121b44f14faa97ed8ce README.rdoc + c7805b18b735aadab8889fd34bceef1d308b4dc63ffd976422342dbac3404d70 exe/rake + 8e43d75374cfcac83933176c32adb3a01d6488aa34c05bf91f6fe8dfe48c52a5 lib/rake.rb + SHASUM + + CHECKSUMS = { + "MIT-LICENSE" => "3e8179ae8e3354a22ad6c9f08a81ac2e7e39cf7b65c7dd97895cb23bf22cd48c", + "README.rdoc" => "70bf070e16ea64e6b9f4d90ec5dd3380a645b4bcdcfe2121b44f14faa97ed8ce", + "exe/rake" => "c7805b18b735aadab8889fd34bceef1d308b4dc63ffd976422342dbac3404d70", + "lib/rake.rb" => "8e43d75374cfcac83933176c32adb3a01d6488aa34c05bf91f6fe8dfe48c52a5" + }.freeze + + context ".generate" do + should "generate a shasum file" do + assert_equal FILE, ShasumFormat.generate(CHECKSUMS) + end + + should "generate an empty file" do + assert_equal "", ShasumFormat.generate({}) + end + + should "tolerate bad input" do + assert_equal "", ShasumFormat.generate({ "foo" => nil, "" => "bar" }) + end + end + + context ".parse" do + should "parse a shasum file we generated" do + assert_equal CHECKSUMS, ShasumFormat.parse(FILE) + end + + should "parse an empty file to an empty hash" do + assert_empty ShasumFormat.parse("") + assert_empty ShasumFormat.parse("").values + end + + should "raise ParseError on malformed file" do + assert_raises(ShasumFormat::ParseError) do + ShasumFormat.parse("boom") + end + end + end +end diff --git a/test/unit/sqs_worker_test.rb b/test/unit/sqs_worker_test.rb new file mode 100644 index 00000000000..05f394ece57 --- /dev/null +++ b/test/unit/sqs_worker_test.rb @@ -0,0 +1,85 @@ +require "test_helper" +require_relative "../../lib/shoryuken/sqs_worker" + +class SqsWorkerTest < ActiveSupport::TestCase + include ActiveJob::TestHelper + + setup do + @sqs_worker = SqsWorker.new + @body = { + "Records" => [{ + "eventVersion" => "2.2", + "eventSource" => "aws => s3", + "awsRegion" => "us-west-2", + "eventTime" => "The time, in ISO-8601 format, for example, 1970-01-01T00 => 00 => 00.000Z, when Amazon S3 finished processing the request", + "eventName" => "event-type", + "userIdentity" => { + "principalId" => "Amazon-customer-ID-of-the-user-who-caused-the-event" + }, + "requestParameters" => { + "sourceIPAddress" => "ip-address-where-request-came-from" + }, + "responseElements" => { + "x-amz-request-id" => "Amazon S3 generated request ID", + "x-amz-id-2" => "Amazon S3 host that processed the request" + }, + "s3" => { + "s3SchemaVersion" => "1.0", + "configurationId" => "ID found in the bucket notification configuration", + "bucket" => { + "name" => "bucket-name", + "ownerIdentity" => { + "principalId" => "Amazon-customer-ID-of-the-bucket-owner" + }, + "arn" => "bucket-ARN" + }, + "object" => { + "key" => "object-key", + "size" => "object-size in bytes", + "eTag" => "object eTag", + "versionId" => "object version if bucket is versioning-enabled, otherwise null", + "sequencer" => "a string representation of a hexadecimal value used to determine event sequence, only used with PUTs and DELETEs" + } + }, + "glacierEventData" => { + "restoreEventData" => { + "lifecycleRestorationExpiryTime" => "The time, in ISO-8601 format, for example, 1970-01-01T00 => 00 => 00.000Z, of Restore Expiry", + "lifecycleRestoreStorageClass" => "Source storage class for restore" + } + } + }] + } + end + + context "#perform" do + should "create Logticket" do + StatsD.expects(:increment).with("fastly_log_processor.s3_entry_fetched") + StatsD.expects(:increment).with("fastly_log_processor.enqueued") + StatsD.expects(:increment).with("rails.enqueue.active_job.success", 1, + has_entry(tags: has_entries(queue: "default", priority: 4, job_class: FastlyLogProcessorJob.name))) + assert_enqueued_jobs 1, only: FastlyLogProcessorJob do + @sqs_worker.perform(nil, @body) + end + + log_ticket = LogTicket.last + + assert_equal "bucket-name", log_ticket.directory + assert_equal "object-key", log_ticket.key + assert_equal "pending", log_ticket.status + end + + should "not create duplicate LogTicket" do + duplicate_record = @body["Records"].first + @body["Records"] << duplicate_record + + StatsD.expects(:increment).with("fastly_log_processor.s3_entry_fetched") + StatsD.expects(:increment).with("fastly_log_processor.enqueued").twice + StatsD.expects(:increment).with("fastly_log_processor.duplicated") + StatsD.expects(:increment).with("rails.enqueue.active_job.success", 1, + has_entry(tags: has_entries(queue: "default", priority: 4, job_class: FastlyLogProcessorJob.name))) + assert_enqueued_jobs 1, only: FastlyLogProcessorJob do + @sqs_worker.perform(nil, @body) + end + end + end +end diff --git a/test/unit/types/duration_test.rb b/test/unit/types/duration_test.rb new file mode 100644 index 00000000000..aeb7dfea82d --- /dev/null +++ b/test/unit/types/duration_test.rb @@ -0,0 +1,35 @@ +require "test_helper" + +class Types::DurationTest < ActiveSupport::TestCase + setup do + @type = Types::Duration.new + end + + test "deserialize iso8601" do + assert_equal 15.minutes, @type.deserialize("PT15M") + end + + test "deserialize seconds as string" do + assert_equal 300.seconds, @type.deserialize("300") + end + + test "deserialize duration" do + assert_equal 300.seconds, @type.deserialize(300.seconds) + end + + test "deserialize unsupported value" do + assert_raise { @type.deserialize(Object.new) } + end + + test "deserialize unsupported string" do + assert_nil @type.deserialize("random string") + end + + test "serialize duration" do + assert_equal "P1D", @type.serialize(1.day) + end + + test "type_cast_for_schema" do + assert_equal '"P1D"', @type.type_cast_for_schema(1.day) + end +end diff --git a/test/unit/update_versions_file_test.rb b/test/unit/update_versions_file_test.rb index 56a5fc35fe0..4bde2da89f8 100644 --- a/test/unit/update_versions_file_test.rb +++ b/test/unit/update_versions_file_test.rb @@ -5,7 +5,13 @@ class UpdateVersionsFileTest < ActiveSupport::TestCase @tmp_versions_file = Tempfile.new("tmp_versions_file") tmp_path = @tmp_versions_file.path Rails.application.config.rubygems.stubs(:[]).with("versions_file_location").returns(tmp_path) - Gemcutter::Application.load_tasks + end + + def update_versions_file + freeze_time do + @created_at = Time.now.utc.iso8601 + Rake::Task["compact_index:update_versions_file"].invoke + end end teardown do @@ -15,11 +21,12 @@ class UpdateVersionsFileTest < ActiveSupport::TestCase context "file header" do setup do - Rake::Task["compact_index:update_versions_file"].invoke + update_versions_file end should "use today's timestamp as header" do - expected_header = "created_at: #{Time.now.iso8601}\n---\n" + expected_header = "created_at: #{@created_at}\n---\n" + assert_equal expected_header, @tmp_versions_file.read end end @@ -40,11 +47,13 @@ class UpdateVersionsFileTest < ActiveSupport::TestCase number: "0.0.1", info_checksum: "qw212r", platform: "jruby") - Rake::Task["compact_index:update_versions_file"].invoke + + update_versions_file end should "include platform release" do expected_output = "rubyrubyruby 0.0.1,0.0.1-jruby qw212r\n" + assert_equal expected_output, @tmp_versions_file.readlines[2] end end @@ -58,11 +67,13 @@ class UpdateVersionsFileTest < ActiveSupport::TestCase number: "0.0.#{4 - i}", info_checksum: "13q4e#{i}") end - Rake::Task["compact_index:update_versions_file"].invoke + + update_versions_file end should "order by created_at and use last released version's info_checksum" do expected_output = "rubyrubyruby 0.0.1,0.0.2,0.0.3 13q4e1\n" + assert_equal expected_output, @tmp_versions_file.readlines[2] end end @@ -87,6 +98,7 @@ class UpdateVersionsFileTest < ActiveSupport::TestCase should "not include yanked version" do expected_output = "rubyrubyruby 0.0.1 qw212r\n" + assert_equal expected_output, @tmp_versions_file.readlines[2] end end @@ -111,11 +123,13 @@ class UpdateVersionsFileTest < ActiveSupport::TestCase created_at: 3.seconds.ago, number: "0.1.3", info_checksum: "zrt13y") - Rake::Task["compact_index:update_versions_file"].invoke + + update_versions_file end should "not include yanked version" do expected_output = "rubyrubyruby 0.1.1,0.1.3 zab45d\n" + assert_equal expected_output, @tmp_versions_file.readlines[2] end end @@ -130,7 +144,8 @@ class UpdateVersionsFileTest < ActiveSupport::TestCase number: "0.1.2", info_checksum: "zsd12q", yanked_info_checksum: "zab45d") - Rake::Task["compact_index:update_versions_file"].invoke + + update_versions_file end should "not include yanked version" do @@ -143,15 +158,16 @@ class UpdateVersionsFileTest < ActiveSupport::TestCase setup do 3.times do |i| create(:rubygem, name: "rubygem#{i}").tap do |gem| - create(:version, rubygem: gem, number: "0.0.1", info_checksum: "13q4e#{i}") + create(:version, rubygem: gem, created_at: 4.seconds.ago, number: "0.0.1", info_checksum: "13q4e#{i}") end end - Rake::Task["compact_index:update_versions_file"].invoke + + update_versions_file end should "put each gem on new line" do expected_output = <<~VERSIONS_FILE - created_at: #{Time.now.iso8601} + created_at: #{@created_at} --- rubygem0 0.0.1 13q4e0 rubygem1 0.0.1 13q4e1 diff --git a/test/unit/user_test.rb b/test/unit/user_test.rb deleted file mode 100644 index 39e40c689e4..00000000000 --- a/test/unit/user_test.rb +++ /dev/null @@ -1,497 +0,0 @@ -require "test_helper" - -class UserTest < ActiveSupport::TestCase - should have_many(:ownerships).dependent(:destroy) - should have_many(:unconfirmed_ownerships).dependent(:destroy) - should have_many(:rubygems).through(:ownerships) - should have_many(:subscribed_gems).through(:subscriptions) - should have_many(:deletions) - should have_many(:subscriptions).dependent(:destroy) - should have_many(:web_hooks).dependent(:destroy) - - context "validations" do - context "handle" do - should allow_value("CapsLOCK").for(:handle) - should_not allow_value("1abcde").for(:handle) - should_not allow_value("abc^%def").for(:handle) - should_not allow_value("abc\n