diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a20a89b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,29 @@ +--- +version: 2 +updates: + - package-ecosystem: bundler + vendor: true + directory: "/" + schedule: + interval: weekly + day: "monday" + time: "21:00" + groups: + prod-ruby-dependencies: + dependency-type: "production" + patterns: + - "*" + dev-ruby-dependencies: + dependency-type: "development" + patterns: + - "*" + - package-ecosystem: github-actions + directory: "/" + groups: + github-actions: + patterns: + - "*" + schedule: + interval: weekly + day: "tuesday" + time: "21:00" diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index 63a91da..7c72519 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -17,7 +17,7 @@ jobs: has_change: ${{ steps.diff.outputs.has_change}} steps: - - uses: actions/checkout@7884fcad6b5d53d10323aee724dc68d8b9096a2e # pin@v2 + - uses: actions/checkout@v6 - id: fetch-base if: github.event_name == 'pull_request' @@ -51,10 +51,10 @@ jobs: echo "::set-output name=has_change::true" fi - acceptance-suite: + acceptance: needs: changes - runs-on: ubuntu-latest - name: runner / acceptance-tests + runs-on: ubuntu-latest-xl + name: acceptance permissions: contents: read @@ -66,9 +66,9 @@ jobs: run: | echo "✅ Bypassing acceptance tests - they are not required for this change" - - name: Check out code + - name: checkout if: ${{ needs.changes.outputs.has_change == 'true' }} - uses: actions/checkout@7884fcad6b5d53d10323aee724dc68d8b9096a2e # pin@v2 + uses: actions/checkout@v6 # Use Docker layer caching for 'docker build' and 'docker-compose build' commands. # https://github.com/satackey/action-docker-layer-caching/releases/tag/v0.0.11 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..43430da --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,45 @@ +name: build + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_call: + +permissions: + contents: read + +jobs: + build: + name: build + + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + + steps: + - name: checkout + uses: actions/checkout@v6 + + - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # pin@v1.295.0 + with: + bundler-cache: true + + - name: bootstrap + run: script/bootstrap + + - name: build + run: | + GEM_NAME=$(ls | grep gemspec | cut -d. -f1) + echo "Attempting to build gem $GEM_NAME..." + gem build $GEM_NAME + if [ $? -eq 0 ]; then + echo "Gem built successfully!" + else + echo "Gem build failed!" + exit 1 + fi diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index eec268e..ce49a3c 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,4 +1,4 @@ -name: "CodeQL" +name: CodeQL on: push: @@ -21,20 +21,20 @@ jobs: strategy: fail-fast: false matrix: - language: [ 'ruby' ] + language: [ 'actions', 'ruby' ] steps: - - name: Checkout repository - uses: actions/checkout@7884fcad6b5d53d10323aee724dc68d8b9096a2e # pin@v2 + - name: checkout + uses: actions/checkout@v6 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@27ea8f8fe5977c00f5b37e076ab846c5bd783b96 # pin@v2 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} - name: Autobuild - uses: github/codeql-action/autobuild@27ea8f8fe5977c00f5b37e076ab846c5bd783b96 # pin@v2 + uses: github/codeql-action/autobuild@v4 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@27ea8f8fe5977c00f5b37e076ab846c5bd783b96 # pin@v2 + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3ed1ba5..de2793f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -6,21 +6,24 @@ on: - main pull_request: +permissions: + contents: read + jobs: - rubocop: - name: runner / rubocop + lint: + name: lint runs-on: ubuntu-latest - permissions: - contents: read steps: - - name: Check out code - uses: actions/checkout@7884fcad6b5d53d10323aee724dc68d8b9096a2e # pin@v2 + - name: checkout + uses: actions/checkout@v6 - - uses: ruby/setup-ruby@8029ebd6e5bd8f4e0d6f7623ea76a01ec5b1010d # pin@v1.110.0 + - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # pin@v1.295.0 with: - ruby-version: 3.1.2 bundler-cache: true + - name: bootstrap + run: script/bootstrap + - name: rubocop run: bundle exec rubocop -c .rubocop.yml lib/ spec/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d308eba --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,61 @@ +name: release + +on: + workflow_dispatch: + push: + branches: + - main + paths: + - lib/version.rb + +permissions: + contents: write + packages: write + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: checkout + uses: actions/checkout@v6 + + - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # pin@v1.295.0 + with: + bundler-cache: true + + - name: bootstrap + run: script/bootstrap + + - name: lint + run: bundle exec rubocop -c .rubocop.yml lib/ spec/ + + - name: test + run: script/test -d -k + + - name: set GEM_NAME from gemspec + run: echo "GEM_NAME=$(ls | grep gemspec | cut -d. -f1)" >> $GITHUB_ENV + + # builds the gem and saves the version to GITHUB_ENV + - name: build + run: echo "GEM_VERSION=$(gem build ${{ env.GEM_NAME }}.gemspec 2>&1 | grep Version | cut -d':' -f 2 | tr -d " \t\n\r")" >> $GITHUB_ENV + + - name: publish to GitHub packages + run: | + export OWNER=$( echo ${{ github.repository }} | cut -d "/" -f 1 ) + GEM_HOST_API_KEY=${{ secrets.GITHUB_TOKEN }} gem push --KEY github --host https://rubygems.pkg.github.com/${OWNER} ${{ env.GEM_NAME }}-${{ env.GEM_VERSION }}.gem + + - name: release + uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # pin@v1.21.0 + with: + artifacts: "${{ env.GEM_NAME }}-${{ env.GEM_VERSION }}.gem" + tag: "v${{ env.GEM_VERSION }}" + generateReleaseNotes: true + + - name: Publish to RubyGems + run: | + mkdir -p ~/.gem + echo -e "---\n:rubygems_api_key: ${{ secrets.RUBYGEMS_API_KEY }}" > ~/.gem/credentials + chmod 0600 ~/.gem/credentials + gem push ${{ env.GEM_NAME }}-${{ env.GEM_VERSION }}.gem + rm ~/.gem/credentials diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index edc9e15..14cf887 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,21 +6,29 @@ on: - main pull_request: +permissions: + contents: read + jobs: - rubocop: - name: runner / rspec + test: + name: test runs-on: ubuntu-latest - permissions: - contents: read + + strategy: + matrix: + ruby: [ '3.1.2', '3.1.4', '3.2.2', '3.2.3', '3.3.0', '3.3.1' ] steps: - - name: Check out code - uses: actions/checkout@7884fcad6b5d53d10323aee724dc68d8b9096a2e # pin@v2 + - name: checkout + uses: actions/checkout@v6 - - uses: ruby/setup-ruby@8029ebd6e5bd8f4e0d6f7623ea76a01ec5b1010d # pin@v1.110.0 + - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # pin@v1.295.0 with: - ruby-version: 3.1.2 bundler-cache: true + ruby-version: ${{ matrix.ruby }} + + - name: bootstrap + run: script/bootstrap - - name: rspec tests - run: script/test -d + - name: test + run: script/test -d -k diff --git a/.gitignore b/.gitignore index 39716c5..8fdfec1 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ coverage/* # Ignore JetBrains IDEs .idea + +tmp/ diff --git a/.rubocop.yml b/.rubocop.yml index f2a38d8..8b0b726 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -3,8 +3,10 @@ inherit_gem: - config/default.yml AllCops: + NewCops: disable + SuggestExtensions: false DisplayCopNames: true - TargetRubyVersion: 2.7.5 + TargetRubyVersion: 3.3 Exclude: - 'bin/*' - 'spec/acceptance/fixtures/**/*' diff --git a/.ruby-version b/.ruby-version index ef538c2..9c25013 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.1.2 +3.3.6 diff --git a/Gemfile.lock b/Gemfile.lock index 2a69b58..6e0c732 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,134 +1,183 @@ PATH remote: . specs: - entitlements-github-plugin (0.2.0) - contracts (= 0.17.0) + entitlements-github-plugin (1.2.0) + contracts (~> 0.17.0) faraday (~> 2.0) faraday-retry (~> 2.0) octokit (~> 4.25) + retryable (~> 3.0, >= 3.0.5) GEM remote: https://rubygems.org/ specs: - activesupport (7.0.3.1) + activesupport (7.1.5.1) + base64 + benchmark (>= 0.3) + bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) + logger (>= 1.4.2) minitest (>= 5.1) + mutex_m + securerandom (>= 0.3) tzinfo (~> 2.0) - addressable (2.8.1) - public_suffix (>= 2.0.2, < 6.0) - ast (2.4.2) - concurrent-ruby (1.1.9) - contracts (0.17) - crack (0.4.5) + addressable (2.8.9) + public_suffix (>= 2.0.2, < 8.0) + ast (2.4.3) + base64 (0.3.0) + benchmark (0.4.1) + bigdecimal (4.0.1) + concurrent-ruby (1.3.5) + connection_pool (2.5.3) + contracts (0.17.3) + crack (1.0.1) + bigdecimal rexml - diff-lcs (1.5.0) + diff-lcs (1.6.2) docile (1.4.0) - entitlements (0.2.0) - concurrent-ruby (= 1.1.9) + drb (2.2.3) + entitlements-app (1.2.1) + concurrent-ruby (~> 1.3, >= 1.3.1) faraday (~> 2.0) - net-ldap (~> 0.17) + logger (~> 1.6) + net-ldap (~> 0.19) octokit (~> 4.18) - optimist (= 3.0.0) - faraday (2.5.2) - faraday-net_http (>= 2.0, < 3.1) - ruby2_keywords (>= 0.0.4) - faraday-net_http (3.0.0) - faraday-retry (2.0.0) + optimist (~> 3.1) + ostruct (~> 0.6.0) + faraday (2.14.1) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-net_http (3.4.2) + net-http (~> 0.5) + faraday-retry (2.4.0) faraday (~> 2.0) - hashdiff (1.0.1) - i18n (1.12.0) + hashdiff (1.2.1) + i18n (1.14.7) concurrent-ruby (~> 1.0) - json (2.6.2) - minitest (5.16.3) - net-ldap (0.17.1) + json (2.19.2) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + logger (1.7.0) + minitest (5.25.5) + mutex_m (0.3.0) + net-http (0.9.1) + uri (>= 0.11.1) + net-ldap (0.20.0) + base64 + ostruct octokit (4.25.1) faraday (>= 1, < 3) sawyer (~> 0.9) - optimist (3.0.0) - parallel (1.22.1) - parser (3.1.2.1) + optimist (3.2.1) + ostruct (0.6.3) + parallel (1.27.0) + parser (3.3.10.2) ast (~> 2.4.1) - public_suffix (5.0.0) - rack (2.2.4) + racc + prism (1.9.0) + public_suffix (6.0.2) + racc (1.8.1) + rack (3.1.20) rainbow (3.1.1) - rake (13.0.6) - regexp_parser (2.5.0) - rexml (3.2.5) - rspec (3.8.0) - rspec-core (~> 3.8.0) - rspec-expectations (~> 3.8.0) - rspec-mocks (~> 3.8.0) - rspec-core (3.8.0) - rspec-support (~> 3.8.0) - rspec-expectations (3.8.6) + rake (13.3.1) + rbs (3.6.1) + logger + regexp_parser (2.11.3) + retryable (3.0.5) + rexml (3.4.4) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.8.0) - rspec-mocks (3.8.2) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.7) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.8.0) - rspec-support (3.8.3) - rubocop (1.29.1) + rspec-support (~> 3.13.0) + rspec-support (3.13.6) + rubocop (1.86.0) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) parallel (~> 1.10) - parser (>= 3.1.0.0) + parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.17.0, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 1.4.0, < 3.0) - rubocop-ast (1.21.0) - parser (>= 3.1.1.0) - rubocop-github (0.17.0) - rubocop - rubocop-performance - rubocop-rails - rubocop-performance (1.13.3) - rubocop (>= 1.7.0, < 2.0) - rubocop-ast (>= 0.4.0) - rubocop-rails (2.15.2) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.1) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-github (0.23.0) + rubocop (>= 1.72) + rubocop-performance (>= 1.24) + rubocop-rails (>= 2.23) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.32.0) activesupport (>= 4.2.0) + lint_roller (~> 1.1) rack (>= 1.1) - rubocop (>= 1.7.0, < 2.0) - ruby-progressbar (1.11.0) - ruby2_keywords (0.0.5) - rugged (0.27.5) - sawyer (0.9.2) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + ruby-lsp (0.26.8) + language_server-protocol (~> 3.17.0) + prism (>= 1.2, < 2.0) + rbs (>= 3, < 5) + ruby-progressbar (1.13.0) + rugged (1.9.0) + sawyer (0.9.3) addressable (>= 2.3.5) faraday (>= 0.17.3, < 3) - simplecov (0.16.1) + securerandom (0.3.2) + simplecov (0.22.0) docile (~> 1.1) - json (>= 1.8, < 3) - simplecov-html (~> 0.10.0) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) simplecov-erb (1.0.1) simplecov (< 1.0) - simplecov-html (0.10.2) - tzinfo (2.0.5) + simplecov-html (0.12.3) + simplecov_json_formatter (0.1.4) + tzinfo (2.0.6) concurrent-ruby (~> 1.0) - unicode-display_width (2.2.0) - vcr (4.0.0) - webmock (3.4.2) - addressable (>= 2.3.6) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) + vcr (6.4.0) + webmock (3.26.2) + addressable (>= 2.8.0) crack (>= 0.3.2) - hashdiff + hashdiff (>= 0.4.0, < 2.0.0) PLATFORMS ruby + x86_64-linux DEPENDENCIES - entitlements (= 0.2.0) + entitlements-app (~> 1.0) entitlements-github-plugin! - rake (= 13.0.6) - rspec (= 3.8.0) - rspec-core (= 3.8.0) - rubocop (= 1.29.1) - rubocop-github (= 0.17.0) - rubocop-performance (= 1.13.3) - rugged (= 0.27.5) - simplecov (= 0.16.1) - simplecov-erb (= 1.0.1) - vcr (= 4.0.0) - webmock (= 3.4.2) + rake (~> 13.2, >= 13.2.1) + rspec (= 3.13.2) + rubocop (~> 1.64) + rubocop-github (~> 0.20) + rubocop-performance (~> 1.21) + ruby-lsp (~> 0.26.1) + rugged (~> 1.7, >= 1.7.2) + simplecov (~> 0.22.0) + simplecov-erb (~> 1.0, >= 1.0.1) + vcr (~> 6.2) + webmock (~> 3.23, >= 3.23.1) BUNDLED WITH - 2.3.19 + 2.5.9 diff --git a/README.md b/README.md index de364c0..dffde10 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # entitlements-github-plugin -[![acceptance](https://github.com/github/entitlements-github-plugin/actions/workflows/acceptance.yml/badge.svg)](https://github.com/github/entitlements-github-plugin/actions/workflows/acceptance.yml) [![test](https://github.com/github/entitlements-github-plugin/actions/workflows/test.yml/badge.svg)](https://github.com/github/entitlements-github-plugin/actions/workflows/test.yml) [![lint](https://github.com/github/entitlements-github-plugin/actions/workflows/lint.yml/badge.svg)](https://github.com/github/entitlements-github-plugin/actions/workflows/lint.yml) [![coverage](https://img.shields.io/badge/coverage-100%25-success)](https://img.shields.io/badge/coverage-100%25-success) [![style](https://img.shields.io/badge/code%20style-rubocop--github-blue)](https://github.com/github/rubocop-github) +[![acceptance](https://github.com/github/entitlements-github-plugin/actions/workflows/acceptance.yml/badge.svg)](https://github.com/github/entitlements-github-plugin/actions/workflows/acceptance.yml) [![test](https://github.com/github/entitlements-github-plugin/actions/workflows/test.yml/badge.svg)](https://github.com/github/entitlements-github-plugin/actions/workflows/test.yml) [![lint](https://github.com/github/entitlements-github-plugin/actions/workflows/lint.yml/badge.svg)](https://github.com/github/entitlements-github-plugin/actions/workflows/lint.yml) [![release](https://github.com/github/entitlements-github-plugin/actions/workflows/release.yml/badge.svg)](https://github.com/github/entitlements-github-plugin/actions/workflows/release.yml) [![build](https://github.com/github/entitlements-github-plugin/actions/workflows/build.yml/badge.svg)](https://github.com/github/entitlements-github-plugin/actions/workflows/build.yml) [![coverage](https://img.shields.io/badge/coverage-100%25-success)](https://img.shields.io/badge/coverage-100%25-success) [![style](https://img.shields.io/badge/code%20style-rubocop--github-blue)](https://github.com/github/rubocop-github) `entitlements-github-plugin` is an [entitlements-app](https://github.com/github/entitlements-app) plugin allowing entitlements configs to be used to manage membership of GitHub.com Organizations and Teams. @@ -56,6 +56,7 @@ Any plugins defined in `lib/entitlements-and-plugins` will be loaded and used at dir: github.com/github/org org: github token: <%= ENV["GITHUB_ORG_TOKEN"] %> + ignore_not_found: false # optional argument to ignore users who are not found in the GitHub instance type: "github_org" ``` @@ -72,6 +73,7 @@ Any plugins defined in `lib/entitlements-and-plugins` will be loaded and used at dir: github.com/github/teams org: github token: <%= ENV["GITHUB_ORG_TOKEN"] %> + ignore_not_found: false # optional argument to ignore users who are not found in the GitHub instance type: "github_team" ``` @@ -82,3 +84,13 @@ For example, if there were a file `github.com/github/teams/new-team.txt` with a Entitlements configs can contain metadata which the plugin will use to make further configuration decisions. `metadata_parent_team_name` - when defined in an entitlements config, the defined team will be made the parent team of this GitHub.com Team. + +## Release 🚀 + +To release a new version of this Gem, do the following: + +1. Update the version number in the [`lib/version.rb`](lib/version.rb) file +2. Run `bundle install` to update the `Gemfile.lock` file with the new version +3. Commit your changes, push them to GitHub, and open a PR + +Once your PR is approved and the changes are merged, a new release will be created automatically by the [`release.yml`](.github/workflows/release.yml) workflow. The latest version of the Gem will be published to the GitHub Package Registry and RubyGems. diff --git a/VERSION b/VERSION deleted file mode 100644 index 0ea3a94..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -0.2.0 diff --git a/entitlements-github-plugin.gemspec b/entitlements-github-plugin.gemspec index d077b54..27c1646 100644 --- a/entitlements-github-plugin.gemspec +++ b/entitlements-github-plugin.gemspec @@ -1,32 +1,37 @@ # frozen_string_literal: true +require_relative "lib/version" + Gem::Specification.new do |s| s.name = "entitlements-github-plugin" - s.version = File.read("VERSION").chomp + s.version = Entitlements::Version::VERSION s.summary = "GitHub dotcom provider for entitlements-app" - s.description = "" + s.description = "Entitlements plugin to manage GitHub Orgs and Team memberships and access" s.authors = ["GitHub, Inc. Security Ops"] s.email = "security@github.com" s.license = "MIT" - s.files = Dir.glob("lib/**/*") + %w[VERSION] + s.files = Dir.glob("lib/**/*") s.homepage = "https://github.com/github/entitlements-github-plugin" s.executables = %w[] - s.add_dependency "contracts", "= 0.17.0" + s.required_ruby_version = ">= 3.0.0" + + s.add_dependency "contracts", "~> 0.17.0" s.add_dependency "faraday", "~> 2.0" s.add_dependency "faraday-retry", "~> 2.0" s.add_dependency "octokit", "~> 4.25" + s.add_dependency "retryable", "~> 3.0", ">= 3.0.5" - s.add_development_dependency "entitlements", "0.2.0" - s.add_development_dependency "rake", "= 13.0.6" - s.add_development_dependency "rspec", "= 3.8.0" - s.add_development_dependency "rspec-core", "= 3.8.0" - s.add_development_dependency "rubocop", "= 1.29.1" - s.add_development_dependency "rubocop-github", "= 0.17.0" - s.add_development_dependency "rubocop-performance", "= 1.13.3" - s.add_development_dependency "rugged", "= 0.27.5" - s.add_development_dependency "simplecov", "= 0.16.1" - s.add_development_dependency "simplecov-erb", "= 1.0.1" - s.add_development_dependency "vcr", "= 4.0.0" - s.add_development_dependency "webmock", "3.4.2" + s.add_development_dependency "entitlements-app", "~> 1.0" + s.add_development_dependency "rake", "~> 13.2", ">= 13.2.1" + s.add_development_dependency "rspec", "= 3.13.2" + s.add_development_dependency "rubocop", "~> 1.64" + s.add_development_dependency "rubocop-github", "~> 0.20" + s.add_development_dependency "rubocop-performance", "~> 1.21" + s.add_development_dependency "ruby-lsp", "~> 0.26.1" + s.add_development_dependency "rugged", "~> 1.7", ">= 1.7.2" + s.add_development_dependency "simplecov", "~> 0.22.0" + s.add_development_dependency "simplecov-erb", "~> 1.0", ">= 1.0.1" + s.add_development_dependency "vcr", "~> 6.2" + s.add_development_dependency "webmock", "~> 3.23", ">= 3.23.1" end diff --git a/lib/entitlements/backend/github_org.rb b/lib/entitlements/backend/github_org.rb index dc73152..96146f3 100644 --- a/lib/entitlements/backend/github_org.rb +++ b/lib/entitlements/backend/github_org.rb @@ -24,3 +24,5 @@ class DuplicateUserError < RuntimeError; end require_relative "github_org/controller" require_relative "github_org/provider" require_relative "github_org/service" +require_relative "../config/retry" +Retry.setup! diff --git a/lib/entitlements/backend/github_org/controller.rb b/lib/entitlements/backend/github_org/controller.rb index 10b8e4e..3f599b1 100644 --- a/lib/entitlements/backend/github_org/controller.rb +++ b/lib/entitlements/backend/github_org/controller.rb @@ -83,7 +83,7 @@ def calculate validate_no_dupes! # calls read() for each group if changes.any? - print_differences(key: group_name, added: [], removed: [], changed: changes, ignored_users: ignored_users) + print_differences(key: group_name, added: [], removed: [], changed: changes, ignored_users:) @actions.concat(changes) else logger.debug "UNCHANGED: No GitHub organization changes for #{group_name}" @@ -120,12 +120,13 @@ def apply(action) Contract String, C::HashOf[String => C::Any] => nil def validate_config!(key, data) spec = COMMON_GROUP_CONFIG.merge({ - "base" => { required: true, type: String }, - "addr" => { required: false, type: String }, - "org" => { required: true, type: String }, - "token" => { required: true, type: String }, - "features" => { required: false, type: Array }, - "ignore" => { required: false, type: Array } + "base" => { required: true, type: String }, + "addr" => { required: false, type: String }, + "org" => { required: true, type: String }, + "token" => { required: true, type: String }, + "features" => { required: false, type: Array }, + "ignore" => { required: false, type: Array }, + "ignore_not_found" => { required: false, type: [FalseClass, TrueClass] }, }) text = "GitHub organization group #{key.inspect}" Entitlements::Util::Util.validate_attr!(spec, data, text) @@ -398,11 +399,11 @@ def categorized_changes if removed.key?(member.downcase) # Already removed from a previous role. Therefore this is a move to a different role. removed.delete(member.downcase) - moved[member.downcase] = { member: member, role: role } + moved[member.downcase] = { member:, role: } else # Not removed from a previous role. Suspect this is an addition to the org (if we later spot a removal # from a role, then the code below will update that to be a move instead). - added[member.downcase] = { member: member, role: role } + added[member.downcase] = { member:, role: } end end @@ -414,12 +415,12 @@ def categorized_changes else # Not added to a previous role. Suspect this is a removal from the org (if we later spot an addition # to another role, then the code above will update that to be a move instead). - removed[member.downcase] = { member: member, role: role } + removed[member.downcase] = { member:, role: } end end end - { added: added, removed: removed, moved: moved } + { added:, removed:, moved: } end # Admins or members who are both `invited` and `pending` do not need to be re-invited. We're waiting for them diff --git a/lib/entitlements/backend/github_org/provider.rb b/lib/entitlements/backend/github_org/provider.rb index c25a0b9..7f5de3b 100644 --- a/lib/entitlements/backend/github_org/provider.rb +++ b/lib/entitlements/backend/github_org/provider.rb @@ -25,7 +25,8 @@ def initialize(config:) org: config.fetch("org"), addr: config.fetch("addr", nil), token: config.fetch("token"), - ou: config.fetch("base") + ou: config.fetch("base"), + ignore_not_found: config.fetch("ignore_not_found", false) ) @role_cache = {} end diff --git a/lib/entitlements/backend/github_org/service.rb b/lib/entitlements/backend/github_org/service.rb index 6efcbd8..65ce75f 100644 --- a/lib/entitlements/backend/github_org/service.rb +++ b/lib/entitlements/backend/github_org/service.rb @@ -44,7 +44,17 @@ def sync(implementation, role) Contract String, String => C::Bool def add_user_to_organization(user, role) Entitlements.logger.debug "#{identifier} add_user_to_organization(user=#{user}, org=#{org}, role=#{role})" - new_membership = octokit.update_organization_membership(org, user: user, role: role) + + begin + new_membership = Retryable.with_context(:default, not: [Octokit::NotFound]) do + octokit.update_organization_membership(org, user:, role:) + end + rescue Octokit::NotFound => e + raise e unless ignore_not_found + + Entitlements.logger.warn "User #{user} not found in GitHub instance #{identifier}, ignoring." + return false + end # Happy path if new_membership[:role] == role @@ -70,7 +80,10 @@ def add_user_to_organization(user, role) Contract String => C::Bool def remove_user_from_organization(user) Entitlements.logger.debug "#{identifier} remove_user_from_organization(user=#{user}, org=#{org})" - result = octokit.remove_organization_membership(org, user: user) + + result = Retryable.with_context(:default) do + octokit.remove_organization_membership(org, user:) + end # If we removed the user, remove them from the cache of members, so that any GitHub team # operations in this organization will ignore this user. diff --git a/lib/entitlements/backend/github_team.rb b/lib/entitlements/backend/github_team.rb index b6b80c0..ae045da 100644 --- a/lib/entitlements/backend/github_team.rb +++ b/lib/entitlements/backend/github_team.rb @@ -4,3 +4,5 @@ require_relative "github_team/models/team" require_relative "github_team/provider" require_relative "github_team/service" +require_relative "../config/retry" +Retry.setup! diff --git a/lib/entitlements/backend/github_team/controller.rb b/lib/entitlements/backend/github_team/controller.rb index 04ddd7d..c8402e4 100644 --- a/lib/entitlements/backend/github_team/controller.rb +++ b/lib/entitlements/backend/github_team/controller.rb @@ -61,12 +61,12 @@ def calculate end if diff[:metadata] && diff[:metadata][:create_team] - added << Entitlements::Models::Action.new(team_slug, provider.read(group), group, group_name, ignored_users: ignored_users) + added << Entitlements::Models::Action.new(team_slug, provider.read(group), group, group_name, ignored_users:) else - changed << Entitlements::Models::Action.new(team_slug, provider.read(group), group, group_name, ignored_users: ignored_users) + changed << Entitlements::Models::Action.new(team_slug, provider.read(group), group, group_name, ignored_users:) end end - print_differences(key: group_name, added: added, removed: [], changed: changed) + print_differences(key: group_name, added:, removed: [], changed:) @actions = added + changed end @@ -110,7 +110,8 @@ def validate_config!(key, data) "base" => { required: true, type: String }, "addr" => { required: false, type: String }, "org" => { required: true, type: String }, - "token" => { required: true, type: String } + "token" => { required: true, type: String }, + "ignore_not_found" => { required: false, type: [FalseClass, TrueClass] }, }) text = "GitHub group #{key.inspect}" Entitlements::Util::Util.validate_attr!(spec, data, text) diff --git a/lib/entitlements/backend/github_team/models/team.rb b/lib/entitlements/backend/github_team/models/team.rb index 6f37441..0be33bf 100644 --- a/lib/entitlements/backend/github_team/models/team.rb +++ b/lib/entitlements/backend/github_team/models/team.rb @@ -27,7 +27,7 @@ def initialize(team_id:, team_name:, members:, ou:, metadata:) @team_id = team_id @team_name = team_name.downcase @team_dn = ["cn=#{team_name.downcase}", ou].join(",") - super(dn: @team_dn, members: Set.new(members.map { |m| m.downcase }), metadata: metadata) + super(dn: @team_dn, members: Set.new(members.map { |m| m.downcase }), metadata:) end end end diff --git a/lib/entitlements/backend/github_team/provider.rb b/lib/entitlements/backend/github_team/provider.rb index 11b6e30..f21841c 100644 --- a/lib/entitlements/backend/github_team/provider.rb +++ b/lib/entitlements/backend/github_team/provider.rb @@ -23,7 +23,8 @@ def initialize(config:) org: config.fetch("org"), addr: config.fetch("addr", nil), token: config.fetch("token"), - ou: config.fetch("base") + ou: config.fetch("base"), + ignore_not_found: config.fetch("ignore_not_found", false) ) @github_team_cache = {} @@ -127,7 +128,7 @@ def commit(entitlement_group) # Create the new team and invalidate the cache if github_team.nil? team_name = entitlement_group.cn.downcase - github.create_team(entitlement_group: entitlement_group) + github.create_team(entitlement_group:) github.invalidate_predictive_cache(entitlement_group) @github_team_cache.delete(team_name) github_team = github.read_team(entitlement_group) @@ -161,14 +162,14 @@ def create_github_team_group(entitlement_group) metadata = entitlement_group.metadata metadata["team_id"] = -999 rescue Entitlements::Models::Group::NoMetadata - metadata = {"team_id" => -999} + metadata = { "team_id" => -999 } end Entitlements::Backend::GitHubTeam::Models::Team.new( team_id: -999, team_name: entitlement_group.cn.downcase, members: Set.new, ou: github.ou, - metadata: metadata + metadata: ) end @@ -198,6 +199,23 @@ def diff_existing_updated_metadata(existing_group, group, base_diff) Entitlements.logger.info "CHANGE github_parent_team from #{existing_parent_team} to #{changed_parent_team} for #{existing_group.dn} in #{github.org}" end end + + existing_maintainers = existing_group.metadata_fetch_if_exists("team_maintainers")&.downcase + changed_maintainers = group.metadata_fetch_if_exists("team_maintainers")&.downcase + if existing_maintainers != changed_maintainers + base_diff[:metadata] ||= {} + if existing_maintainers.nil? && !changed_maintainers.nil? + base_diff[:metadata][:team_maintainers] = "add" + Entitlements.logger.info "ADD github_team_maintainers #{changed_maintainers} to #{existing_group.dn} in #{github.org}" + elsif !existing_maintainers.nil? && changed_maintainers.nil? + base_diff[:metadata][:team_maintainers] = "remove" + Entitlements.logger.info "REMOVE (NOOP) github_team_maintainers #{existing_maintainers} from #{existing_group.dn} in #{github.org}" + else + base_diff[:metadata][:team_maintainers] = "change" + Entitlements.logger.info "CHANGE github_team_maintainers from #{existing_maintainers} to #{changed_maintainers} for #{existing_group.dn} in #{github.org}" + end + end + base_diff end diff --git a/lib/entitlements/backend/github_team/service.rb b/lib/entitlements/backend/github_team/service.rb index 28decb0..6a993f1 100644 --- a/lib/entitlements/backend/github_team/service.rb +++ b/lib/entitlements/backend/github_team/service.rb @@ -4,6 +4,7 @@ require_relative "../../service/github" require "base64" +require "set" module Entitlements class Backend @@ -17,23 +18,25 @@ class TeamNotFound < RuntimeError; end # Constructor. # - # addr - Base URL a GitHub Enterprise API (leave undefined to use dotcom) - # org - String with organization name - # token - Access token for GitHub API - # ou - Base OU for fudged DNs + # addr - Base URL a GitHub Enterprise API (leave undefined to use dotcom) + # org - String with organization name + # token - Access token for GitHub API + # ou - Base OU for fudged DNs + # ignore_not_found - Boolean to ignore not found errors # # Returns nothing. Contract C::KeywordArgs[ addr: C::Maybe[String], org: String, token: String, - ou: String + ou: String, + ignore_not_found: C::Maybe[C::Bool], ] => C::Any - def initialize(addr: nil, org:, token:, ou:) + def initialize(org:, token:, ou:, addr: nil, ignore_not_found: false) super Entitlements.cache[:github_team_members] ||= {} - Entitlements.cache[:github_team_members][org] ||= {} - @team_cache = Entitlements.cache[:github_team_members][org] + Entitlements.cache[:github_team_members][org_signature] ||= {} + @team_cache = Entitlements.cache[:github_team_members][org_signature] end # Read a single team identified by its slug and return a team object. @@ -77,7 +80,7 @@ def read_team(entitlement_group) team_id: -1, team_name: team_identifier, members: cached_members, - ou: ou, + ou:, metadata: team_metadata ) @@ -103,15 +106,19 @@ def read_team(entitlement_group) end end + maintainers = teamdata[:members].select { |u| teamdata[:roles][u] == "maintainer" } + team_metadata ||= {} + team_metadata = team_metadata.merge({ "team_maintainers" => maintainers.any? ? maintainers.join(",") : nil }) + team = Entitlements::Backend::GitHubTeam::Models::Team.new( team_id: teamdata[:team_id], team_name: team_identifier, members: Set.new(teamdata[:members]), - ou: ou, + ou:, metadata: team_metadata ) rescue TeamNotFound - Entitlements.logger.warn "Team #{team_identifier} does not exist in this GitHub.com organization" + Entitlements.logger.warn "Team #{team_identifier} does not exist in this GitHub.com organization. If applied, the team will be created." return nil end @@ -132,7 +139,7 @@ def read_team(entitlement_group) def from_predictive_cache?(entitlement_group) team_identifier = entitlement_group.cn.downcase read_team(entitlement_group) unless @team_cache[team_identifier] - (@team_cache[team_identifier] && @team_cache[team_identifier][:cache]) ? true : false + @team_cache[team_identifier] && @team_cache[team_identifier][:cache] ? true : false end # Declare the entry to be invalid for a specific team, and if the prior knowledge @@ -185,7 +192,7 @@ def sync_team(desired_state, current_state) if desired_metadata["parent_team_name"].nil? Entitlements.logger.debug "sync_team(team=#{current_state.team_name}): IGNORING GitHub Parent Team DELETE" else - # :nocov: + # :nocov: Entitlements.logger.debug "sync_team(#{current_state.team_name}=#{current_state.team_id}): Parent team change found - From #{current_metadata["parent_team_name"] || "No Parent Team"} to #{desired_metadata["parent_team_name"]}" desired_parent_team_id = team_by_name(org_name: org, team_name: desired_metadata["parent_team_name"])[:id] unless desired_parent_team_id.nil? @@ -196,14 +203,59 @@ def sync_team(desired_state, current_state) end end - added_members = desired_state.member_strings.map { |u| u.downcase } - current_state.member_strings.map { |u| u.downcase } - removed_members = current_state.member_strings.map { |u| u.downcase } - desired_state.member_strings.map { |u| u.downcase } + desired_team_members = Set.new(desired_state.member_strings.map { |u| u.downcase }) + current_team_members = Set.new(current_state.member_strings.map { |u| u.downcase }) + + added_members = desired_team_members - current_team_members + removed_members = current_team_members - desired_team_members added_members.select! { |username| add_user_to_team(user: username, team: current_state) } removed_members.select! { |username| remove_user_from_team(user: username, team: current_state) } + added_maintainers = Set.new + removed_maintainers = Set.new + unless desired_metadata["team_maintainers"] == current_metadata["team_maintainers"] + if desired_metadata["team_maintainers"].nil? + # We will not delete ALL maintainers from a team and leave it without maintainers. + Entitlements.logger.debug "sync_team(#{current_state.team_name}=#{current_state.team_id}): IGNORING GitHub Team Maintainer DELETE" + else + desired_maintainers_str = desired_metadata["team_maintainers"] # not nil, we tested that above + desired_maintainers = Set.new(desired_maintainers_str.split(",").map { |u| u.strip.downcase }) + unless desired_maintainers.subset?(desired_team_members) + maintainer_not_member = desired_maintainers - desired_team_members + Entitlements.logger.warn "sync_team(#{current_state.team_name}=#{current_state.team_id}): Maintainers must be a subset of team members. Desired maintainers: #{maintainer_not_member.to_a} are not members. Ignoring." + desired_maintainers = desired_maintainers.intersection(desired_team_members) + end + + current_maintainers_str = current_metadata["team_maintainers"] + current_maintainers = Set.new( + current_maintainers_str.nil? ? [] : current_maintainers_str.split(",").map { |u| u.strip.downcase } + ) + # We ignore any current maintainer who is not a member of the team according to the team spec + # This avoids messing with teams who have been manually modified to add a maintainer + current_maintainers = current_maintainers.intersection(desired_team_members) + added_maintainers = desired_maintainers - current_maintainers + removed_maintainers = current_maintainers - desired_maintainers + if added_maintainers.empty? && removed_maintainers.empty? + Entitlements.logger.debug "sync_team(#{current_state.team_name}=#{current_state.team_id}): Textual change but no semantic change in maintainers. It is remains: #{current_maintainers.to_a}." + else + Entitlements.logger.debug "sync_team(#{current_state.team_name}=#{current_state.team_id}): Maintainer members change found - From #{current_maintainers.to_a} to #{desired_maintainers.to_a}" + added_maintainers.select! do |username| + add_user_to_team(user: username, team: current_state, role: "maintainer") + end + + ## We only touch previous maintainers who are actually still going to be members of the team + removed_maintainers = removed_maintainers.intersection(desired_team_members) + ## Downgrade membership to default (role: "member") + removed_maintainers.select! do |username| + add_user_to_team(user: username, team: current_state, role: "member") + end + end + end + end + Entitlements.logger.debug "sync_team(#{current_state.team_name}=#{current_state.team_id}): Added #{added_members.count}, removed #{removed_members.count}" - added_members.any? || removed_members.any? || changed_parent_team + added_members.any? || removed_members.any? || added_maintainers.any? || removed_maintainers.any? || changed_parent_team end # Create a team @@ -215,28 +267,47 @@ def sync_team(desired_state, current_state) entitlement_group: Entitlements::Models::Group, ] => C::Bool def create_team(entitlement_group:) + team_name = entitlement_group.cn.downcase + team_options = { name: team_name, repo_names: [], privacy: "closed" } + begin - team_name = entitlement_group.cn.downcase - team_options = { name: team_name, repo_names: [], privacy: "closed" } + entitlement_metadata = entitlement_group.metadata + unless entitlement_metadata["parent_team_name"].nil? - begin - entitlement_metadata = entitlement_group.metadata - unless entitlement_metadata["parent_team_name"].nil? + begin parent_team_data = graphql_team_data(entitlement_metadata["parent_team_name"]) team_options[:parent_team_id] = parent_team_data[:team_id] - Entitlements.logger.debug "create_team(team=#{team_name}) Parent team #{entitlement_metadata["parent_team_name"]} with id #{parent_team_data[:team_id]} found" + rescue TeamNotFound + # if the parent team does not exist, create it (think `mkdir -p` logic here) + result = Retryable.with_context(:default, not: [Octokit::UnprocessableEntity]) do + octokit.create_team( + org, + { name: entitlement_metadata["parent_team_name"], repo_names: [], privacy: "closed" } + ) + end + + Entitlements.logger.debug "created parent team #{entitlement_metadata["parent_team_name"]} with id #{result[:id]}" + + team_options[:parent_team_id] = result[:id] end - rescue Entitlements::Models::Group::NoMetadata - Entitlements.logger.debug "create_team(team=#{team_name}) No metadata found" + + Entitlements.logger.debug "create_team(team=#{team_name}) Parent team #{entitlement_metadata["parent_team_name"]} with id #{team_options[:parent_team_id]} found" end + rescue Entitlements::Models::Group::NoMetadata + Entitlements.logger.debug "create_team(team=#{team_name}) No metadata found" + end - Entitlements.logger.debug "create_team(team=#{team_name})" + Entitlements.logger.debug "create_team(team=#{team_name})" + + result = Retryable.with_context(:default, not: [Octokit::UnprocessableEntity]) do octokit.create_team(org, team_options) - true - rescue Octokit::UnprocessableEntity => e - Entitlements.logger.debug "create_team(team=#{team_name}) ERROR - #{e.message}" - false end + + Entitlements.logger.debug "created team #{team_name} with id #{result[:id]}" + true + rescue Octokit::UnprocessableEntity => e + Entitlements.logger.debug "create_team(team=#{team_name}) ERROR - #{e.message}" + false end # Update a team @@ -249,15 +320,17 @@ def create_team(entitlement_group:) metadata: C::Or[Hash, nil] ] => C::Bool def update_team(team:, metadata: {}) - begin - Entitlements.logger.debug "update_team(team=#{team.team_name})" - options = { name: team.team_name, repo_names: [], privacy: "closed", parent_team_id: metadata[:parent_team_id] } + Entitlements.logger.debug "update_team(team=#{team.team_name})" + options = { name: team.team_name, repo_names: [], privacy: "closed", + parent_team_id: metadata[:parent_team_id] } + Retryable.with_context(:default, not: [Octokit::UnprocessableEntity]) do octokit.update_team(team.team_id, options) - true - rescue Octokit::UnprocessableEntity => e - Entitlements.logger.debug "update_team(team=#{team.team_name}) ERROR - #{e.message}" - false end + + true + rescue Octokit::UnprocessableEntity => e + Entitlements.logger.debug "update_team(team=#{team.team_name}) ERROR - #{e.message}" + false end # Gets a team by name @@ -270,7 +343,9 @@ def update_team(team:, metadata: {}) team_name: String ] => Sawyer::Resource def team_by_name(org_name:, team_name:) - octokit.team_by_name(org_name, team_name) + Retryable.with_context(:default) do + octokit.team_by_name(org_name, team_name) + end end private @@ -283,11 +358,13 @@ def team_by_name(org_name:, team_name:) # team_slug - Identifier of the team to retrieve. # # Returns a data structure with team data. - Contract String => { members: C::ArrayOf[String], team_id: Integer, parent_team_name: C::Or[String, nil] } + Contract String => { members: C::ArrayOf[String], team_id: Integer, parent_team_name: C::Or[String, nil], + roles: C::HashOf[String => String] } def graphql_team_data(team_slug) cursor = nil team_id = nil result = [] + roles = {} sanity_counter = 0 while sanity_counter < 100 @@ -305,6 +382,7 @@ def graphql_team_data(team_slug) node { login } + role cursor } } @@ -319,9 +397,7 @@ def graphql_team_data(team_slug) end team = response[:data].fetch("data").fetch("organization").fetch("team") - if team.nil? - raise TeamNotFound, "Requested team #{team_slug} does not exist in #{org}!" - end + raise TeamNotFound, "Requested team #{team_slug} does not exist in #{org}!" if team.nil? team_id = team.fetch("databaseId") parent_team_name = team.dig("parentTeam", "slug") @@ -332,12 +408,18 @@ def graphql_team_data(team_slug) buffer = edges.map { |e| e.fetch("node").fetch("login").downcase } result.concat buffer + edges.each do |e| + role = e.fetch("role").downcase + roles[e.fetch("node").fetch("login").downcase] = role + end + cursor = edges.last.fetch("cursor") next if cursor && buffer.size == max_graphql_results + break end - { members: result, team_id: team_id, parent_team_name: parent_team_name } + { members: result, team_id:, parent_team_name:, roles: } end # Ensure that the given team ID actually matches up to the team slug on GitHub. This is in place @@ -355,10 +437,14 @@ def validate_team_id_and_slug!(team_id, team_slug) @validation_cache ||= {} @validation_cache[team_id] ||= begin Entitlements.logger.debug "validate_team_id_and_slug!(#{team_id}, #{team_slug.inspect})" - team_data = octokit.team(team_id) + team_data = Retryable.with_context(:default) do + octokit.team(team_id) + end + team_data[:slug] end return if @validation_cache[team_id] == team_slug + raise "validate_team_id_and_slug! mismatch: team_id=#{team_id} expected=#{team_slug.inspect} got=#{@validation_cache[team_id].inspect}" end @@ -366,18 +452,41 @@ def validate_team_id_and_slug!(team_id, team_slug) # # user - String with the GitHub username # team - Entitlements::Backend::GitHubTeam::Models::Team object for the team. + # role - optional (default: "member") String with the role to assign to the user: either "member" or "maintainer" # - # Returns true if the user was added to the team, false if user was already on team. + # Returns true if the user was added to the team or role changed; false if user was already on team with same role Contract C::KeywordArgs[ user: String, team: Entitlements::Backend::GitHubTeam::Models::Team, + role: C::Optional[String] ] => C::Bool - def add_user_to_team(user:, team:) + def add_user_to_team(user:, team:, role: "member") return false unless org_members.include?(user.downcase) - Entitlements.logger.debug "#{identifier} add_user_to_team(user=#{user}, org=#{org}, team_id=#{team.team_id})" + unless ["member", "maintainer"].include?(role) + # :nocov: + raise "add_user_to_team role mismatch: team_id=#{team.team_id} user=#{user} expected role=maintainer/member got=#{role}" + end + + Entitlements.logger.debug "#{identifier} add_user_to_team(user=#{user}, org=#{org}, team_id=#{team.team_id}, role=#{role})" validate_team_id_and_slug!(team.team_id, team.team_name) - result = octokit.add_team_membership(team.team_id, user) - result[:state] == "active" || result[:state] == "pending" + + begin + result = Retryable.with_context(:default, not: [Octokit::UnprocessableEntity, Octokit::NotFound]) do + octokit.add_team_membership(team.team_id, user, role:) + end + + result[:state] == "active" || result[:state] == "pending" + rescue Octokit::UnprocessableEntity => e + raise e unless ignore_not_found && e.message =~ /Enterprise Managed Users must be part of the organization to be assigned to the team/ + + Entitlements.logger.warn "User #{user} not found in organization #{org}, ignoring." + false + rescue Octokit::NotFound => e + raise e unless ignore_not_found + + Entitlements.logger.warn "User #{user} not found in GitHub instance #{identifier}, ignoring." + false + end end # Remove user from team. @@ -392,9 +501,13 @@ def add_user_to_team(user:, team:) ] => C::Bool def remove_user_from_team(user:, team:) return false unless org_members.include?(user.downcase) + Entitlements.logger.debug "#{identifier} remove_user_from_team(user=#{user}, org=#{org}, team_id=#{team.team_id})" validate_team_id_and_slug!(team.team_id, team.team_name) - octokit.remove_team_membership(team.team_id, user) + + Retryable.with_context(:default) do + octokit.remove_team_membership(team.team_id, user) + end end end end diff --git a/lib/entitlements/config/retry.rb b/lib/entitlements/config/retry.rb new file mode 100644 index 0000000..3dea667 --- /dev/null +++ b/lib/entitlements/config/retry.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "retryable" + +module Retry + # This method should be called as early as possible in the startup of your application + # It sets up the Retryable gem with custom contexts and passes through a few options + # Should the number of retries be reached without success, the last exception will be raised + def self.setup! + ######## Retryable Configuration ######## + # All defaults available here: + # https://github.com/nfedyashev/retryable/blob/6a04027e61607de559e15e48f281f3ccaa9750e8/lib/retryable/configuration.rb#L22-L33 + Retryable.configure do |config| + config.contexts[:default] = { + on: [StandardError], + sleep: 1, + tries: 3 + } + end + end +end diff --git a/lib/entitlements/service/github.rb b/lib/entitlements/service/github.rb index 7c33d70..ec9af19 100644 --- a/lib/entitlements/service/github.rb +++ b/lib/entitlements/service/github.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require_relative "../config/retry" + require "net/http" require "octokit" require "uri" @@ -17,28 +19,34 @@ class GitHub MAX_GRAPHQL_RETRIES = 3 WAIT_BETWEEN_GRAPHQL_RETRIES = 1 - attr_reader :addr, :org, :token, :ou + attr_reader :addr, :org, :token, :ou, :ignore_not_found # Constructor. # - # addr - Base URL a GitHub Enterprise API (leave undefined to use dotcom) - # org - String with organization name - # token - Access token for GitHub API - # ou - Base OU for fudged DNs + # addr - Base URL a GitHub Enterprise API (leave undefined to use dotcom) + # org - String with organization name + # token - Access token for GitHub API + # ou - Base OU for fudged DNs + # ignore_not_found - Boolean to ignore not found errors # # Returns nothing. Contract C::KeywordArgs[ addr: C::Maybe[String], org: String, token: String, - ou: String + ou: String, + ignore_not_found: C::Maybe[C::Bool], ] => C::Any - def initialize(addr: nil, org:, token:, ou:) + def initialize(addr: nil, org:, token:, ou:, ignore_not_found: false) + # init the retry module + Retry.setup! + # Save some parameters for the connection but don't actually connect yet. @addr = addr @org = org @token = token @ou = ou + @ignore_not_found = ignore_not_found # This is a global cache across all invocations of this object. GitHub membership # need to be obtained only one time per organization, but might be used multiple times. @@ -82,7 +90,7 @@ def org_members member_count = result.count { |_, role| role == "member" } Entitlements.logger.debug "Currently #{org} has #{admin_count} admin(s) and #{member_count} member(s)" - { cache: cache, value: result } + { cache:, value: result } end Entitlements.cache[:github_org_members][org_signature][:value] @@ -91,7 +99,10 @@ def org_members # Returns true if the github instance is an enterprise server instance Contract C::None => C::Bool def enterprise? - meta = octokit.github_meta + meta = Retryable.with_context(:default) do + octokit.github_meta + end + meta.key? :installed_version end @@ -160,6 +171,7 @@ def octokit client = Octokit::Client.new(access_token: token) client.api_endpoint = addr if addr client.auto_paginate = true + client.per_page = 100 Entitlements.logger.debug "Setting up GitHub API connection to #{client.api_endpoint}" client end @@ -210,8 +222,8 @@ def members_and_roles_from_graphql login } role - cursor } + pageInfo { endCursor } } } }".gsub(/\n\s+/, "\n") @@ -222,14 +234,15 @@ def members_and_roles_from_graphql raise "GraphQL query failure" end - edges = response[:data].fetch("data").fetch("organization").fetch("membersWithRole").fetch("edges") + membersWithRole = response[:data].fetch("data").fetch("organization").fetch("membersWithRole") + edges = membersWithRole.fetch("edges") break unless edges.any? edges.each do |edge| result[edge.fetch("node").fetch("login").downcase] = edge.fetch("role") end - cursor = edges.last.fetch("cursor") + cursor = membersWithRole.fetch("pageInfo").fetch("endCursor") next if cursor && edges.size == max_graphql_results break end @@ -242,11 +255,22 @@ def members_and_roles_from_graphql def members_and_roles_from_rest Entitlements.logger.debug "Loading organization members and roles for #{org}" result = {} - members = octokit.organization_members(org, { role: "admin" }) - members.each do |member| + + # fetch all the admin members from the org + admin_members = Retryable.with_context(:default) do + octokit.organization_members(org, { role: "admin" }) + end + + # fetch all the regular members from the org + regular_members = Retryable.with_context(:default) do + octokit.organization_members(org, { role: "member" }) + end + + admin_members.each do |member| result[member[:login].downcase] = "ADMIN" end - octokit.organization_members(org, { role: "member" }).each do |member| + + regular_members.each do |member| result[member[:login].downcase] = "MEMBER" end @@ -276,8 +300,8 @@ def pending_members_from_graphql node { login } - cursor } + pageInfo { endCursor } } } }".gsub(/\n\s+/, "\n") @@ -288,14 +312,15 @@ def pending_members_from_graphql raise "GraphQL query failure" end - edges = response[:data].fetch("data").fetch("organization").fetch("pendingMembers").fetch("edges") + pendingMembers = response[:data].fetch("data").fetch("organization").fetch("pendingMembers") + edges = pendingMembers.fetch("edges") break unless edges.any? edges.each do |edge| result.add(edge.fetch("node").fetch("login").downcase) end - cursor = edges.last.fetch("cursor") + cursor = pendingMembers.fetch("pageInfo").fetch("endCursor") next if cursor && edges.size == max_graphql_results break end @@ -320,7 +345,7 @@ def graphql_http_post(query) return result else Entitlements.logger.warn "GraphQL failed on try #{try_number} of #{MAX_GRAPHQL_RETRIES}. Will retry." - sleep WAIT_BETWEEN_GRAPHQL_RETRIES * (2 ** (try_number - 1)) + sleep WAIT_BETWEEN_GRAPHQL_RETRIES * (2**(try_number - 1)) end end end @@ -354,9 +379,9 @@ def graphql_http_post_real(query) data = JSON.parse(response.body) if data.key?("errors") Entitlements.logger.error "Errors reported: #{data['errors'].inspect}" - return { code: 500, data: data } + return { code: 500, data: } end - { code: response.code.to_i, data: data } + { code: response.code.to_i, data: } rescue JSON::ParserError => e Entitlements.logger.error "#{e.class} #{e.message}: #{response.body.inspect}" { code: 500, data: { "body" => response.body } } diff --git a/lib/version.rb b/lib/version.rb new file mode 100644 index 0000000..34d3bc2 --- /dev/null +++ b/lib/version.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Entitlements + module Version + VERSION = "1.2.0" + end +end diff --git a/script/bootstrap b/script/bootstrap index ae08178..3cd5402 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -1,15 +1,54 @@ -#!/bin/bash +#! /usr/bin/env bash -set -e -set -x +# COLORS +OFF='\033[0m' +RED='\033[0;31m' +GREEN='\033[0;32m' +BLUE='\033[0;34m' +PURPLE='\033[0;35m' + +set -e # Prevent any kind of script failures + +# if any of the following env vars are set, use them for the APP_ENV value +if [ -n "$APP_ENV" ]; then + export APP_ENV="$APP_ENV" +elif [ -n "$ENV" ]; then + export APP_ENV="$ENV" +elif [ -n "$ENVIRONMENT" ]; then + export APP_ENV="$ENVIRONMENT" +elif [ -n "$RAILS_ENV" ]; then + export APP_ENV="$RAILS_ENV" +elif [ -n "$RACK_ENV" ]; then + export APP_ENV="$RACK_ENV" +fi + +# set the working directory to the root of the project DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && cd .. && pwd )" -cd "$DIR" -export PATH=/usr/share/rbenv/shims:$PATH -export RBENV_VERSION="$(cat .ruby-version)" -rm -rf "${DIR}/.bundle" +# set the ruby version to the one specified in the .ruby-version file +[ -z "$RBENV_VERSION" ] && export RBENV_VERSION=$(cat "$DIR/.ruby-version") + +# set the app environment to development if it's not set +[ -z "$APP_ENV" ] && export APP_ENV="development" + +# set the path to include the rbenv shims if they exist +[ -d "/usr/share/rbenv/shims" ] && export PATH=/usr/share/rbenv/shims:$PATH -# Using Deprecated Flags to avoid pulling from upstream -bundle install --path vendor/gems --local --clean +TRASHDIR=$(mktemp -d /tmp/bootstrap.XXXXXXXXXXXXXXXXX) +cleanup() { + rm -rf "$TRASHDIR" + # Remove empty directory + rmdir "$DIR/vendor/cache" 2>/dev/null || true +} +trap cleanup EXIT -bundle binstubs rake rspec-core rubocop +# Bootstrap gem dependencies. +if [ "$APP_ENV" == "production" ]; then + echo -e "💎 ${BLUE}Installing Gems for ${GREEN}production${BLUE}...${OFF}" + BUNDLE_WITHOUT=development bundle install --local + BUNDLE_WITHOUT=development bundle binstubs --all +else + echo -e "💎 ${BLUE}Installing Gems for ${PURPLE}development${BLUE}...${OFF}" + bundle install --local + bundle binstubs --all +fi diff --git a/script/cibuild-entitlements-github-plugin-acceptance b/script/cibuild-entitlements-github-plugin-acceptance index 2fd4992..4859f53 100755 --- a/script/cibuild-entitlements-github-plugin-acceptance +++ b/script/cibuild-entitlements-github-plugin-acceptance @@ -23,7 +23,7 @@ end_fold() { } docker_compose() { - cd "$DIR" && docker-compose -f "$DIR/spec/acceptance/docker-compose.yml" "$@" + cd "$DIR" && docker compose -f "$DIR/spec/acceptance/docker-compose.yml" "$@" } unset DOCKER_COMPOSE_NEEDS_SHUTDOWN diff --git a/script/release b/script/release deleted file mode 100755 index cdbc892..0000000 --- a/script/release +++ /dev/null @@ -1,40 +0,0 @@ -#!/bin/bash -# Tag and push a release. - -set -e -set -x - -# Make sure we're in the project root. - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && cd .. && pwd )" -cd ${DIR} - -# Build a new gem archive. - -rm -rf entitlements-github-plugin-*.gem -gem build -q entitlements-github-plugin.gemspec - -# Make sure we're on the main branch. - -(git branch --no-color | grep -q '* main') || { - echo "Only release from the main branch." - exit 1 -} - -# Figure out what version we're releasing. - -tag=v`ls entitlements-github-plugin-*.gem | sed 's/^entitlements-github-plugin-\(.*\)\.gem$/\1/'` - -# Make sure we haven't released this version before. - -git fetch -t origin - -(git tag -l | grep -q "$tag") && { - echo "Whoops, there's already a '${tag}' tag." - exit 1 -} - -# Tag it and bag it. - -gem push entitlements-github-plugin-*.gem && git tag "$tag" && - git push origin main && git push origin "$tag" diff --git a/script/test b/script/test index ab02e2c..0fff564 100755 --- a/script/test +++ b/script/test @@ -1,15 +1,22 @@ -#!/bin/bash +#! /usr/bin/env bash # run script/test -h for help +# COLORS +OFF='\033[0m' +RED='\033[0;31m' +GREEN='\033[0;32m' +BLUE='\033[0;34m' + set -e function usage() { echo -e "\t ================== script/test usage ==================" echo -e "\t-h --help : displays help message" + echo -e "\t-k --no-linter : disables linting tests" echo -e "\t-d --disable-bootstrap : disables bootstrap" - echo -e "\n\t Suggested flags for development: script/test -d" + echo -e "\n\t Suggested flags for development: script/test -d -s" } while [ "$1" != "" ]; do @@ -35,42 +42,64 @@ while [ "$1" != "" ]; do shift done -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && cd .. && pwd )" - -export PATH=/usr/share/rbenv/shims:$PATH -export RBENV_VERSION="$(cat "${DIR}/.ruby-version")" - -TRASHDIR=$(mktemp -d /tmp/cibuild.XXXXXXXXXXXXXXXXXX) -cleanup() { - rm -rf "$TRASHDIR" -} -trap cleanup EXIT - -cd "$DIR" -. "${DIR}/script/lib/fold.sh" +# setup +export DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && cd .. && pwd )" +[ -z "$RBENV_VERSION" ] && export RBENV_VERSION=$(cat "$DIR/.ruby-version") if [[ -z $no_bootstrap ]]; then # bootstrap - begin_fold "Bootstrapping" - ./script/bootstrap - end_fold + echo -e "\n🥾 ${BLUE}Bootstrapping: $(date "+%H:%M:%S")${OFF}\n" + echo "%%%FOLD {bootstrap}%%%" + cd "$DIR" + script/bootstrap + echo "%%%END FOLD%%%" else - echo -e "\nBypass Bootstrap" + echo -e "\n⏩ ${BLUE}Skipping Bootstrap${OFF}" fi -bundle exec rspec spec/unit && rspec_exit=$? || rspec_exit=$? +# Run Rubocop +if [[ -z $no_linter ]]; then + echo -e "\n🤖 ${BLUE}Running Rubocop: $(date "+%H:%M:%S")${OFF}\n" + bundle exec bin/rubocop +else + echo -e "\n⏩ ${BLUE}Skipping Rubocop${OFF}" +fi + +# run tests +echo -e "\n🧪 ${BLUE}Running tests: $(date "+%H:%M:%S")${OFF}\n" +cd "$(dirname $0)/.." -cat "$DIR/coverage/coverage.txt" -grep -q "You're all set, friend" "$DIR/coverage/coverage.txt" && cov_exit=0 || cov_exit=1 +bundle exec bin/rspec spec/unit && rspec_exit=$? || rspec_exit=$? + +total_coverage=$(cat "$DIR/coverage/total-coverage.txt") + +if grep -q "100.0" "$DIR/coverage/total-coverage.txt"; then + cov_exit=0 + echo -e "\n✅ Total Coverage: ${GREEN}$total_coverage${OFF}" +else + cov_exit=1 + echo -e "\n❌ Total Coverage: ${RED}$total_coverage${OFF}" +fi echo "" echo "---------------------------------------" -echo "Summary Results" +echo "📊 Summary Results" echo "---------------------------------------" echo "" -echo "rspec: exitcode=${rspec_exit}" -echo "coverage: exitcode=${cov_exit}" + +if [[ $rspec_exit == 0 ]]; then + echo -e "✅ ${GREEN}rspec: exitcode=${rspec_exit}${OFF}" +else + echo -e "❌ ${RED}rspec: exitcode=${rspec_exit}${OFF}" +fi + +if [[ $cov_exit == 0 ]]; then + echo -e "✅ \033[0;32mcoverage: exitcode=${cov_exit}\033[0m" +else + echo -e "❌ \033[0;31mcoverage: exitcode=${cov_exit}\033[0m" +fi [ $rspec_exit -gt 0 ] && exit 1 [ $cov_exit -gt 0 ] && exit 1 + exit 0 diff --git a/script/vendor-gem b/script/vendor-gem deleted file mode 100755 index 68a7a14..0000000 --- a/script/vendor-gem +++ /dev/null @@ -1,148 +0,0 @@ -#!/bin/sh -#/ Usage: script/vendor-gem [-r ] [-n ] [-d ] -#/ Build a gem for the given git repository and stick it in vendor/cache. With -r, build -#/ the gem at the branch, tag, or SHA1 given. With no -r, build the default HEAD. -#/ With -d, build the gem at the given directory within the repository. -#/ -#/ This command is used in situations where you'd typically use a :git bundler -#/ source which should not be used in the main github app (even for development gems). -set -e -[[ $TRACE ]] && set -x - -# write out compare url for review -[ $# -eq 0 ] && set -- --help - -# parse args -rev=master -directory="." -while [ $# -gt 0 ]; do - case "$1" in - -d) - directory=$2 - shift 2 - ;; - -r) - rev=$2 - shift 2 - ;; - -n) - gem=$2 - shift 2 - ;; - -h|--help) - grep ^#/ <"$0" |cut -c4- - exit - ;; - *) - url="$1" - shift - ;; - esac -done - -if [ -z "$url" ]; then - echo "error: no git url given. see $0 --help for usage." 1>&2 - exit 1 -fi - -repo=$(echo "$url" | sed 's@^\(https://github\.com.*\)\.git$@\1@') - -if [ -z "$gem" ]; then - gem=$(basename "$url" .git) -fi - -# the RAILS_ROOT directory -root=$(cd $(dirname "$0")/.. && pwd) -cd "$root" - -gem_directory="$root/tmp/gems/$gem/$directory" - -# clone the repo under tmp, clean up on exit -echo "Cloning $url for gem build" -mkdir -p "tmp/gems/$gem" - -# go in and build the gem using the HEAD version, clean up this tmp dir on exit -echo "Building $gem" -( - cd "tmp/gems/$gem" - git init -q - git fetch -q -fu "$url" "+refs/*:refs/*" - git reset --hard HEAD - git clean -df - git checkout "$rev" - git submodule update --init - git --no-pager log -n 1 - - cd "$gem_directory" - gemspec=$(ls -1 *.gemspec | head -1) - echo "Building $gemspec" - - gemname=$(basename "$gemspec" .gemspec) - echo $gemname > vendor-gem-name - - # tag name + number of commits on top of tag + tree sha - GEM_VERSION="" - - # No tags - if [ -z "${GEM_VERSION}" ] - then - gem_version=$(ruby -e "require 'rubygems'; spec=eval(File.read('$gemspec')); print spec.version.to_s") - tree_sha=$(git show --quiet --format=format:%t $rev) - GEM_VERSION="${gem_version}.g${tree_sha}" - fi - - if [ -z "${GEM_VERSION}" ] - then - echo "couldn't determine the gem version from \"$gemspec\"" - exit 1 - fi - - export GEM_VERSION - - # build a wrapping gemspec that adds the sha1 version to the gem version - # unless the gemspec references the GEM_VERSION environment variable - # in which case we assume this is handled explicitly in the gemspec itself - if ! grep -q "GEM_VERSION" < $gemspec - then - cat <<-RUBY > vendor.gemspec - require 'rubygems' - spec = eval(File.read("$gemspec")) - spec.version = "$GEM_VERSION" - spec -RUBY - gem build vendor.gemspec - else - gem build $gemspec - fi - - cd "$root/tmp/gems/$gem" - # Bump gem version in Gemfile (and deal with OS X sed differences) - sed -i -e "s/^gem ['\"]$gemname['\"],\( *\)['\"]\([^'\"]*\)['\"]/gem \"$gemname\",\\1\"$GEM_VERSION\"/" ../../../Gemfile - if [ `uname` = 'Darwin' ]; then - rm -f "../../../Gemfile-e" - fi -) -[ $? -eq 0 ] || exit 1 - -# get the gem name determined in the subprocess -gemname=$(cat "$gem_directory/vendor-gem-name") - -# record old gem ref before deleting -oldref=$(ls vendor/cache/$gemname-*.gem | grep -o -E -e "g[0-9a-f]{7}" | cut -c 2-) - -# remove any existing gems and add the newly built gem -if [ -n "$gemname" ]; then - git rm -f vendor/cache/$gemname*.gem 2>/dev/null || true - cp "$gem_directory/$gemname"*.gem vendor/cache - git add vendor/cache/$gemname* -fi - -# get new gem ref -newref=$(ls vendor/cache/$gemname-*.gem | grep -o -E -e "g[0-9a-f]{7}" | cut -c 2-) - -# write out compare url for review -echo "$repo/compare/$oldref...$newref" - -rm -rf "tmp" -bundle update --local $gemname -git add Gemfile Gemfile.lock diff --git a/spec/acceptance/Dockerfile.entitlements-github-plugin b/spec/acceptance/Dockerfile.entitlements-github-plugin index 00d146a..1479c75 100644 --- a/spec/acceptance/Dockerfile.entitlements-github-plugin +++ b/spec/acceptance/Dockerfile.entitlements-github-plugin @@ -1,4 +1,4 @@ -FROM ruby:3.1-slim +FROM ruby:3.3.1-slim LABEL maintainer="GitHub Security Ops " ENV HOME /root ENV RELEASE=buster @@ -17,12 +17,13 @@ RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \ pkg-config # Install bundler -RUN gem install bundler +RUN gem install bundler -v 2.5.9 # Bootstrap files and caching for speed COPY "vendor/cache/" "/data/entitlements/vendor/cache/" COPY "script/" "/data/entitlements/script/" -COPY [".rubocop.yml", ".ruby-version", "entitlements-github-plugin.gemspec", "Gemfile", "Gemfile.lock", "VERSION", "/data/entitlements/"] +COPY [".rubocop.yml", ".ruby-version", "entitlements-github-plugin.gemspec", "Gemfile", "Gemfile.lock", "/data/entitlements/"] +COPY "lib/version.rb" "/data/entitlements/lib/version.rb" RUN ./script/bootstrap # Source Files diff --git a/spec/acceptance/docker-compose.yml b/spec/acceptance/docker-compose.yml index 170b09b..c778e7e 100644 --- a/spec/acceptance/docker-compose.yml +++ b/spec/acceptance/docker-compose.yml @@ -28,6 +28,7 @@ services: volumes: - "${DIR}/spec/acceptance:/acceptance:ro" - "${DIR}/spec/acceptance/git-server/keys:/git-server/keys:ro" + ldap-server: entrypoint: /acceptance/ldap-server/run-server.sh image: osixia/openldap:1.2.2 @@ -39,6 +40,7 @@ services: - "127.0.0.1:636:636" volumes: - "${DIR}/spec/acceptance:/acceptance:ro" + github-server: build: context: "${DIR}/spec/acceptance/github-server" @@ -49,7 +51,6 @@ services: - github.fake ports: - "127.0.0.1:443:443" + - "127.0.0.1:80:80" volumes: - "${DIR}/spec/acceptance:/acceptance:ro" - ports: - - "127.0.0.1:80:80" diff --git a/spec/acceptance/github-server/Dockerfile b/spec/acceptance/github-server/Dockerfile index d61e32b..0f4b56e 100644 --- a/spec/acceptance/github-server/Dockerfile +++ b/spec/acceptance/github-server/Dockerfile @@ -1,4 +1,4 @@ -FROM ruby:3.1-slim +FROM ruby:3.3-slim LABEL maintainer="GitHub Security Ops " # Install dependency packages for bootstrapping and running... diff --git a/spec/acceptance/github-server/Gemfile b/spec/acceptance/github-server/Gemfile index 4fde03a..bee7c83 100644 --- a/spec/acceptance/github-server/Gemfile +++ b/spec/acceptance/github-server/Gemfile @@ -1,7 +1,9 @@ # frozen_string_literal: true + source "https://rubygems.org" -gem "rack" -gem "rack-test" -gem "sinatra" -gem "webrick" +gem "rack", "~> 3.1.8" +gem "rack-test", "~> 2.1" +gem "rackup", "~> 2.2" +gem "sinatra", "~> 4.1.1" +gem "webrick", "~> 1.8" diff --git a/spec/acceptance/github-server/web.rb b/spec/acceptance/github-server/web.rb index cdb3414..7124f05 100644 --- a/spec/acceptance/github-server/web.rb +++ b/spec/acceptance/github-server/web.rb @@ -8,18 +8,25 @@ require "webrick/https" require "openssl" -webrick_options = { - Host: "0.0.0.0", - Port: 443, - Logger: WEBrick::Log::new($stderr, WEBrick::Log::DEBUG), - SSLEnable: true, - SSLVerifyClient: OpenSSL::SSL::VERIFY_NONE, - SSLCertificate: OpenSSL::X509::Certificate.new(File.read("/acceptance/github-server/ssl.crt")), - SSLPrivateKey: OpenSSL::PKey::RSA.new(File.read("/acceptance/github-server/ssl.key")), - SSLCertName: [["CN", "github.fake"]] -} - class FakeGitHubApi < Sinatra::Base + use Rack::RewindableInput::Middleware + + set :server, %w[webrick] + set :server_settings, { + Host: "0.0.0.0", + Port: 443, + Logger: WEBrick::Log::new($stderr, WEBrick::Log::DEBUG), + SSLEnable: true, + SSLVerifyClient: OpenSSL::SSL::VERIFY_NONE, + SSLCertificate: OpenSSL::X509::Certificate.new(File.read("/acceptance/github-server/ssl.crt")), + SSLPrivateKey: OpenSSL::PKey::RSA.new(File.read("/acceptance/github-server/ssl.key")), + SSLCertName: [["CN", "github.fake"]] + } + + set :port, 443 + set :bind, "0.0.0.0" + set :host_authorization, { permitted_hosts: [] } + BASE_DIR = "/tmp/github" TEAM_MAP_FILE = File.join(BASE_DIR, "team_map.json") @@ -73,7 +80,7 @@ def graphql_team_query(query) cursor_flag = cursor.nil? members.each do |m| next if !cursor_flag && Base64.strict_encode64(m) != cursor - edges << { "node" => { "login" => m }, "cursor" => Base64.strict_encode64(m) } if cursor_flag + edges << { "node" => { "login" => m }, "role" => "MEMBER", "cursor" => Base64.strict_encode64(m) } if cursor_flag cursor_flag = true break if edges.size >= first end @@ -113,9 +120,11 @@ def graphql_org_query(query) edges = [] cursor_flag = cursor.nil? + end_cursor = nil result.each do |user, role| - next if !cursor_flag && Base64.strict_encode64(user) != cursor - edges << { "node" => { "login" => user }, "role" => role, "cursor" => Base64.strict_encode64(user) } if cursor_flag + end_cursor = Base64.strict_encode64(user) + next if !cursor_flag && end_cursor != cursor + edges << { "node" => { "login" => user }, "role" => role } if cursor_flag cursor_flag = true break if edges.size >= first end @@ -123,7 +132,10 @@ def graphql_org_query(query) { "organization" => { "membersWithRole" => { - "edges" => edges + "edges" => edges, + "pageInfo" => { + "endCursor" => end_cursor + } } } } @@ -144,9 +156,11 @@ def graphql_pending_query(query) edges = [] cursor_flag = cursor.nil? + end_cursor = nil result.each do |user| - next if !cursor_flag && Base64.strict_encode64(user) != cursor - edges << { "node" => { "login" => user }, "cursor" => Base64.strict_encode64(user) } if cursor_flag + end_cursor = Base64.strict_encode64(user) + next if !cursor_flag && end_cursor != cursor + edges << { "node" => { "login" => user } } if cursor_flag cursor_flag = true break if edges.size >= first end @@ -154,7 +168,10 @@ def graphql_pending_query(query) { "organization" => { "pendingMembers" => { - "edges" => edges + "edges" => edges, + "pageInfo" => { + "endCursor" => end_cursor + } } } } @@ -250,7 +267,7 @@ def graphql_pending_query(query) query = postdata["query"] result = if query =~ /team\(slug:/ - graphql_team_query(query) + graphql_team_query(query) elsif query =~ /membersWithRole\(/ graphql_org_query(query) elsif query =~ /pendingMembers\(/ @@ -367,7 +384,8 @@ def graphql_pending_query(query) File.open(TEAM_MAP_FILE, "w") do |f| f.write(JSON.pretty_generate(tmp_map)) end - halt 201 + + [201, { "Content-Type" => "application/json" }, [JSON.generate(teamdata)]] end send :get, "/orgs/:org_name/teams/:team_name" do @@ -387,6 +405,7 @@ def graphql_pending_query(query) halt 201 end + # rubocop:disable AvoidObjectSendWithDynamicMethod [:get, :patch, :put, :delete, :post].each do |verb| send verb, "/*" do raise "No route registered for #{params}. Take a look in #{__FILE__}" @@ -394,4 +413,4 @@ def graphql_pending_query(query) end end -Rack::Handler::WEBrick.run FakeGitHubApi, **webrick_options +FakeGitHubApi.run! diff --git a/spec/acceptance/support/run-app.sh b/spec/acceptance/support/run-app.sh index e0e0dd0..2657317 100755 --- a/spec/acceptance/support/run-app.sh +++ b/spec/acceptance/support/run-app.sh @@ -31,7 +31,7 @@ begin_fold "Bootstrapping" cd "$DIR" mkdir -p .git/hooks # So bootstrap doesn't fail to create symlinks script/bootstrap 1>&2 -bundle binstubs entitlements +bundle binstubs entitlements-app end_fold begin_fold "Verifying network connectivity to the LDAP container" diff --git a/spec/acceptance/tests/01_basic_webserver_github_connectivity_spec.rb b/spec/acceptance/tests/01_basic_webserver_github_connectivity_spec.rb index 0db62e2..ac97c5f 100644 --- a/spec/acceptance/tests/01_basic_webserver_github_connectivity_spec.rb +++ b/spec/acceptance/tests/01_basic_webserver_github_connectivity_spec.rb @@ -51,6 +51,7 @@ node { login } + role cursor } }, @@ -78,13 +79,13 @@ "databaseId" => 6, "members" => { "edges" => [ - { "node" => { "login" => "cheetoh" }, "cursor" => "Y2hlZXRvaA==" }, - { "node" => { "login" => "khaomanee" }, "cursor" => "a2hhb21hbmVl" }, - { "node" => { "login" => "nebelung" }, "cursor" => "bmViZWx1bmc=" }, - { "node" => { "login" => "ojosazules" }, "cursor" => "b2pvc2F6dWxlcw==" } + { "node" => { "login" => "cheetoh" }, "role" => "MEMBER", "cursor" => "Y2hlZXRvaA==" }, + { "node" => { "login" => "khaomanee" }, "role" => "MEMBER", "cursor" => "a2hhb21hbmVl" }, + { "node" => { "login" => "nebelung" }, "role" => "MEMBER", "cursor" => "bmViZWx1bmc=" }, + { "node" => { "login" => "ojosazules" }, "role" => "MEMBER", "cursor" => "b2pvc2F6dWxlcw==" } ] }, - "parentTeam" => { "slug" => nil } + "parentTeam" => { "slug" => nil }, } } } diff --git a/spec/acceptance/tests/spec_helper.rb b/spec/acceptance/tests/spec_helper.rb index 3c665ba..97afc35 100644 --- a/spec/acceptance/tests/spec_helper.rb +++ b/spec/acceptance/tests/spec_helper.rb @@ -64,7 +64,7 @@ def run(fixture_dir, args = []) command_parts = [binary, "--config-file", configfile] + args command = command_parts.map { |i| Shellwords.escape(i) }.join(" ") stdout, stderr, exitstatus = Open3.capture3(command) - OpenStruct.new({ stdout: stdout, stderr: stderr, exitstatus: exitstatus.exitstatus, success?: exitstatus.exitstatus == 0 }) + OpenStruct.new({ stdout:, stderr:, exitstatus: exitstatus.exitstatus, success?: exitstatus.exitstatus == 0 }) end def log(priority, pattern) @@ -83,7 +83,7 @@ def ldap_obj host: uri.host, port: uri.port, encryption: (uri.scheme == "ldaps" ? :simple_tls : nil), - auth: {method: :simple, username: ENV["LDAP_BINDDN"], password: ENV["LDAP_BINDPW"]} + auth: { method: :simple, username: ENV["LDAP_BINDDN"], password: ENV["LDAP_BINDPW"] } ) ldap_object.bind diff --git a/spec/unit/entitlements/backend/github_org/controller_spec.rb b/spec/unit/entitlements/backend/github_org/controller_spec.rb index 149dc21..f9b202a 100644 --- a/spec/unit/entitlements/backend/github_org/controller_spec.rb +++ b/spec/unit/entitlements/backend/github_org/controller_spec.rb @@ -9,10 +9,11 @@ let(:backend_config) { base_backend_config } let(:base_backend_config) do { - "org" => "kittensinc", - "token" => "CuteAndCuddlyKittens", - "type" => "github_org", - "base" => "ou=kittensinc,ou=GitHub,dc=github,dc=com" + "org" => "kittensinc", + "token" => "CuteAndCuddlyKittens", + "type" => "github_org", + "base" => "ou=kittensinc,ou=GitHub,dc=github,dc=com", + "ignore_not_found" => false } end let(:group_name) { "foo-githuborg" } @@ -98,9 +99,10 @@ it "logs expected output and returns expected actions" do allow(Entitlements::Data::Groups::Calculated).to receive(:read_all) .with("foo-githuborg", { - "base" => "ou=kittensinc,ou=GitHub,dc=github,dc=com", - "org" => "kittensinc", - "token" => "CuteAndCuddlyKittens" + "base" => "ou=kittensinc,ou=GitHub,dc=github,dc=com", + "org" => "kittensinc", + "token" => "CuteAndCuddlyKittens", + "ignore_not_found" => false }).and_return(Set.new(%w[admin member].map { |cn| "cn=#{cn},ou=kittensinc,ou=GitHub,dc=github,dc=com" })) allow(Entitlements::Data::Groups::Calculated).to receive(:read).with(org1_admin_dn).and_return(org1_admin_group) allow(Entitlements::Data::Groups::Calculated).to receive(:read).with(org1_member_dn).and_return(org1_member_group) @@ -149,10 +151,10 @@ context "with pending members" do let(:org1_members_response) do { - "toyger" => "admin", - "highlander" => "admin", - "blackmanx" => "member", - "russianblue" => "member" + "toyger" => "admin", + "highlander" => "admin", + "blackmanx" => "member", + "russianblue" => "member" } end @@ -160,7 +162,7 @@ let(:org2_members_response) do { - "russianblue" => "admin" + "russianblue" => "admin" } end @@ -179,7 +181,8 @@ end it "logs expected output and returns expected actions" do - allow(Entitlements::Data::Groups::Calculated).to receive(:read_all).with("foo-githuborg", {"base"=>"ou=kittensinc,ou=GitHub,dc=github,dc=com", "org"=>"kittensinc", "token"=>"CuteAndCuddlyKittens"}) + allow(Entitlements::Data::Groups::Calculated).to receive(:read_all) + .with("foo-githuborg", { "base" => "ou=kittensinc,ou=GitHub,dc=github,dc=com", "org" => "kittensinc", "token" => "CuteAndCuddlyKittens", "ignore_not_found" => false }) .and_return(Set.new(%w[admin member].map { |cn| "cn=#{cn},ou=kittensinc,ou=GitHub,dc=github,dc=com" })) allow(Entitlements::Data::Groups::Calculated).to receive(:read).with(org1_admin_dn).and_return(org1_admin_group) allow(Entitlements::Data::Groups::Calculated).to receive(:read).with(org1_member_dn).and_return(org1_member_group) @@ -263,7 +266,8 @@ end it "logs expected output and returns expected actions" do - allow(Entitlements::Data::Groups::Calculated).to receive(:read_all).with("foo-githuborg", {"base"=>"ou=kittensinc,ou=GitHub,dc=github,dc=com", "org"=>"kittensinc", "token"=>"CuteAndCuddlyKittens"}) + allow(Entitlements::Data::Groups::Calculated).to receive(:read_all) + .with("foo-githuborg", { "base" => "ou=kittensinc,ou=GitHub,dc=github,dc=com", "org" => "kittensinc", "token" => "CuteAndCuddlyKittens", "ignore_not_found" => false }) .and_return(Set.new(%w[admin member].map { |cn| "cn=#{cn},ou=kittensinc,ou=GitHub,dc=github,dc=com" })) allow(Entitlements::Data::Groups::Calculated).to receive(:read).with(org1_admin_dn).and_return(org1_admin_group) allow(Entitlements::Data::Groups::Calculated).to receive(:read).with(org1_member_dn).and_return(org1_member_group) @@ -314,21 +318,21 @@ { "ragamuffin" => "admin", "mainecoon" => "admin", - "blackmanx" => "member", - "highlander" => "member", + "blackmanx" => "member", + "highlander" => "member", "peterbald" => "member" } end let(:org2_members_response) do { - "russianblue" => "admin" + "russianblue" => "admin" } end it "does not run actions" do allow(Entitlements::Data::Groups::Calculated).to receive(:read_all) - .with("foo-githuborg", {"base"=>"ou=kittensinc,ou=GitHub,dc=github,dc=com", "org"=>"kittensinc", "token"=>"CuteAndCuddlyKittens"}) + .with("foo-githuborg", { "base" => "ou=kittensinc,ou=GitHub,dc=github,dc=com", "org" => "kittensinc", "token" => "CuteAndCuddlyKittens", "ignore_not_found" => false }) .and_return(Set.new(%w[admin member].map { |cn| "cn=#{cn},ou=kittensinc,ou=GitHub,dc=github,dc=com" })) allow(Entitlements::Data::Groups::Calculated).to receive(:read).with(org1_admin_dn).and_return(org1_admin_group) allow(Entitlements::Data::Groups::Calculated).to receive(:read).with(org1_member_dn).and_return(org1_member_group) @@ -374,7 +378,7 @@ it "handles removals and role changes but does not invite" do allow(Entitlements::Data::Groups::Calculated).to receive(:read_all) - .with("foo-githuborg", {"base"=>"ou=kittensinc,ou=GitHub,dc=github,dc=com", "features"=>%w[remove], "org"=>"kittensinc", "token"=>"CuteAndCuddlyKittens"}) + .with("foo-githuborg", { "base" => "ou=kittensinc,ou=GitHub,dc=github,dc=com", "features" => %w[remove], "org" => "kittensinc", "token" => "CuteAndCuddlyKittens", "ignore_not_found" => false }) .and_return(Set.new(%w[admin member].map { |cn| "cn=#{cn},ou=kittensinc,ou=GitHub,dc=github,dc=com" })) allow(Entitlements::Data::Groups::Calculated).to receive(:read).with(org1_admin_dn).and_return(org1_admin_group) allow(Entitlements::Data::Groups::Calculated).to receive(:read).with(org1_member_dn).and_return(org1_member_group) @@ -410,8 +414,8 @@ "MAINECOON" ])) expect(result[0].implementation).to eq([ - { action: :add, person: "RagaMuffin"}, - { action: :remove, person: "russianblue"} + { action: :add, person: "RagaMuffin" }, + { action: :remove, person: "russianblue" } ]) expect(result[1]).to be_a_kind_of(Entitlements::Models::Action) @@ -421,7 +425,7 @@ "peterbald" ])) expect(result[1].implementation).to eq([ - { action: :remove, person: "toyger"} + { action: :remove, person: "toyger" } ]) end end @@ -437,7 +441,7 @@ it "reports as a no-op" do allow(Entitlements::Data::Groups::Calculated).to receive(:read_all) - .with("foo-githuborg", {"base"=>"ou=kittensinc,ou=GitHub,dc=github,dc=com", "features"=>%w[remove], "org"=>"kittensinc", "token"=>"CuteAndCuddlyKittens"}) + .with("foo-githuborg", { "base" => "ou=kittensinc,ou=GitHub,dc=github,dc=com", "features" => %w[remove], "org" => "kittensinc", "token" => "CuteAndCuddlyKittens", "ignore_not_found" => false }) .and_return(Set.new(%w[admin member].map { |cn| "cn=#{cn},ou=kittensinc,ou=GitHub,dc=github,dc=com" })) allow(Entitlements::Data::Groups::Calculated).to receive(:read).with(org1_admin_dn).and_return(org1_admin_group) allow(Entitlements::Data::Groups::Calculated).to receive(:read).with(org1_member_dn).and_return(org1_member_group) @@ -486,7 +490,7 @@ it "handles removals and role changes but does not invite" do allow(Entitlements::Data::Groups::Calculated).to receive(:read_all) - .with("foo-githuborg", {"base"=>"ou=kittensinc,ou=GitHub,dc=github,dc=com", "features"=>%w[invite], "org"=>"kittensinc", "token"=>"CuteAndCuddlyKittens"}) + .with("foo-githuborg", { "base" => "ou=kittensinc,ou=GitHub,dc=github,dc=com", "features" => %w[invite], "org" => "kittensinc", "token" => "CuteAndCuddlyKittens", "ignore_not_found" => false }) .and_return(Set.new(%w[admin member].map { |cn| "cn=#{cn},ou=kittensinc,ou=GitHub,dc=github,dc=com" })) allow(Entitlements::Data::Groups::Calculated).to receive(:read).with(org1_admin_dn).and_return(org1_admin_group) allow(Entitlements::Data::Groups::Calculated).to receive(:read).with(org1_member_dn).and_return(org1_member_group) @@ -523,7 +527,7 @@ "MAINECOON" ])) expect(result[0].implementation).to eq([ - { action: :add, person: "RagaMuffin"} + { action: :add, person: "RagaMuffin" } ]) expect(result[1]).to be_a_kind_of(Entitlements::Models::Action) @@ -535,7 +539,7 @@ "peterbald" ])) expect(result[1].implementation).to eq([ - { action: :add, person: "blackmanx"} + { action: :add, person: "blackmanx" } ]) end end @@ -555,7 +559,7 @@ it "reports as a no-op" do allow(Entitlements::Data::Groups::Calculated).to receive(:read_all) - .with("foo-githuborg", {"base"=>"ou=kittensinc,ou=GitHub,dc=github,dc=com", "features"=>%w[invite], "org"=>"kittensinc", "token"=>"CuteAndCuddlyKittens"}) + .with("foo-githuborg", { "base" => "ou=kittensinc,ou=GitHub,dc=github,dc=com", "features" => %w[invite], "org" => "kittensinc", "token" => "CuteAndCuddlyKittens", "ignore_not_found" => false }) .and_return(Set.new(%w[admin member].map { |cn| "cn=#{cn},ou=kittensinc,ou=GitHub,dc=github,dc=com" })) allow(Entitlements::Data::Groups::Calculated).to receive(:read).with(org1_admin_dn).and_return(org1_admin_group) allow(Entitlements::Data::Groups::Calculated).to receive(:read).with(org1_member_dn).and_return(org1_member_group) @@ -588,12 +592,11 @@ let(:answer2) { { "monalisa" => "ADMIN", "ragamuffin" => "ADMIN", "blackmanx" => "MEMBER", "toyger" => "MEMBER" } } it "invalidates the cache and consults the API" do - cache[:predictive_state] = { by_dn: { org1_admin_dn => { members: admins, metadata: nil }, org1_member_dn => { members: members, metadata: nil } }, invalid: Set.new } + cache[:predictive_state] = { by_dn: { org1_admin_dn => { members: admins, metadata: nil }, org1_member_dn => { members:, metadata: nil } }, invalid: Set.new } allow(Entitlements::Data::Groups::Calculated).to receive(:read_all) - .with("foo-githuborg", { - "base"=>"ou=kittensinc,ou=GitHub,dc=github,dc=com", "org"=>"kittensinc", "token"=>"CuteAndCuddlyKittens" - }).and_return(Set.new(%w[admin member].map { |cn| "cn=#{cn},ou=kittensinc,ou=GitHub,dc=github,dc=com" })) + .with("foo-githuborg", { "base" => "ou=kittensinc,ou=GitHub,dc=github,dc=com", "org" => "kittensinc", "token" => "CuteAndCuddlyKittens", "ignore_not_found" => false }) + .and_return(Set.new(%w[admin member].map { |cn| "cn=#{cn},ou=kittensinc,ou=GitHub,dc=github,dc=com" })) allow(Entitlements::Data::Groups::Calculated).to receive(:read).with(org1_admin_dn).and_return(org1_admin_group) allow(Entitlements::Data::Groups::Calculated).to receive(:read).with(org1_member_dn).and_return(org1_member_group) @@ -663,7 +666,7 @@ it "handles removals and role changes but does not invite" do allow(Entitlements::Data::Groups::Calculated).to receive(:read_all) - .with("foo-githuborg", {"base"=>"ou=kittensinc,ou=GitHub,dc=github,dc=com", "features"=>[], "org"=>"kittensinc", "token"=>"CuteAndCuddlyKittens"}) + .with("foo-githuborg", { "base" => "ou=kittensinc,ou=GitHub,dc=github,dc=com", "features" => [], "org" => "kittensinc", "token" => "CuteAndCuddlyKittens", "ignore_not_found" => false }) .and_return(Set.new(%w[admin member].map { |cn| "cn=#{cn},ou=kittensinc,ou=GitHub,dc=github,dc=com" })) allow(Entitlements::Data::Groups::Calculated).to receive(:read).with(org1_admin_dn).and_return(org1_admin_group) allow(Entitlements::Data::Groups::Calculated).to receive(:read).with(org1_member_dn).and_return(org1_member_group) @@ -699,7 +702,7 @@ "MAINECOON" ])) expect(result[0].implementation).to eq([ - { action: :add, person: "RagaMuffin"} + { action: :add, person: "RagaMuffin" } ]) expect(result[1]).to be_a_kind_of(Entitlements::Models::Action) @@ -726,7 +729,7 @@ it "reports as a no-op" do allow(Entitlements::Data::Groups::Calculated).to receive(:read_all) - .with("foo-githuborg", {"base"=>"ou=kittensinc,ou=GitHub,dc=github,dc=com", "features"=>[], "org"=>"kittensinc", "token"=>"CuteAndCuddlyKittens"}) + .with("foo-githuborg", { "base" => "ou=kittensinc,ou=GitHub,dc=github,dc=com", "features" => [], "org" => "kittensinc", "token" => "CuteAndCuddlyKittens", "ignore_not_found" => false }) .and_return(Set.new(%w[admin member].map { |cn| "cn=#{cn},ou=kittensinc,ou=GitHub,dc=github,dc=com" })) allow(Entitlements::Data::Groups::Calculated).to receive(:read).with(org1_admin_dn).and_return(org1_admin_group) allow(Entitlements::Data::Groups::Calculated).to receive(:read).with(org1_member_dn).and_return(org1_member_group) @@ -837,7 +840,7 @@ describe "#validate_github_org_ous!" do it "raises if an admin or member group is missing" do allow(Entitlements::Data::Groups::Calculated).to receive(:read_all) - .with("foo-githuborg", {"base"=>"ou=kittensinc,ou=GitHub,dc=github,dc=com", "org"=>"kittensinc", "token"=>"CuteAndCuddlyKittens"}) + .with("foo-githuborg", { "base" => "ou=kittensinc,ou=GitHub,dc=github,dc=com", "org" => "kittensinc", "token" => "CuteAndCuddlyKittens", "ignore_not_found" => false }) .and_return(Set.new(%w[member].map { |cn| "cn=#{cn},ou=kittensinc,ou=GitHub,dc=github,dc=com" })) github_double = instance_double(Entitlements::Backend::GitHubOrg::Provider) @@ -857,7 +860,7 @@ dns = %w[admin member kittens cats].map { |cn| "cn=#{cn},ou=kittensinc,ou=GitHub,dc=github,dc=com" } allow(Entitlements::Data::Groups::Calculated).to receive(:read_all) - .with("foo-githuborg", {"base"=>"ou=kittensinc,ou=GitHub,dc=github,dc=com", "org"=>"kittensinc", "token"=>"CuteAndCuddlyKittens"}) + .with("foo-githuborg", { "base" => "ou=kittensinc,ou=GitHub,dc=github,dc=com", "org" => "kittensinc", "token" => "CuteAndCuddlyKittens", "ignore_not_found" => false }) .and_return(Set.new(dns)) allow(Entitlements::Backend::GitHubOrg::Service).to receive(:new).and_return(service) @@ -897,11 +900,8 @@ it "raises due to duplicate users" do allow(Entitlements::Data::Groups::Calculated).to receive(:read_all) - .with("foo-githuborg", { - "base" => "ou=kittensinc,ou=GitHub,dc=github,dc=com", - "org" => "kittensinc", - "token" => "CuteAndCuddlyKittens" - }).and_return(Set.new(%w[admin member].map { |cn| "cn=#{cn},ou=kittensinc,ou=GitHub,dc=github,dc=com" })) + .with("foo-githuborg", { "base" => "ou=kittensinc,ou=GitHub,dc=github,dc=com", "org" => "kittensinc", "token" => "CuteAndCuddlyKittens", "ignore_not_found" => false }) + .and_return(Set.new(%w[admin member].map { |cn| "cn=#{cn},ou=kittensinc,ou=GitHub,dc=github,dc=com" })) allow(Entitlements::Data::Groups::Calculated).to receive(:read).with(admin_dn).and_return(admin_group) allow(Entitlements::Data::Groups::Calculated).to receive(:read).with(member_dn).and_return(member_group) allow(Entitlements::Data::Groups::Calculated).to receive(:read).with(member_dn).and_return(member_group) diff --git a/spec/unit/entitlements/backend/github_org/provider_spec.rb b/spec/unit/entitlements/backend/github_org/provider_spec.rb index 7a02ab1..b0e91f3 100644 --- a/spec/unit/entitlements/backend/github_org/provider_spec.rb +++ b/spec/unit/entitlements/backend/github_org/provider_spec.rb @@ -7,7 +7,8 @@ addr: "https://github.fake/api/v3", org: "kittensinc", token: "GoPackGo", - ou: "ou=kittensinc,ou=GitHub,dc=github,dc=fake" + ou: "ou=kittensinc,ou=GitHub,dc=github,dc=fake", + ignore_not_found: false } end diff --git a/spec/unit/entitlements/backend/github_org/service_spec.rb b/spec/unit/entitlements/backend/github_org/service_spec.rb index 791f4c8..6aebce7 100644 --- a/spec/unit/entitlements/backend/github_org/service_spec.rb +++ b/spec/unit/entitlements/backend/github_org/service_spec.rb @@ -11,7 +11,8 @@ addr: "https://github.fake/api/v3", org: "kittensinc", token: "GoPackGo", - ou: "ou=kittensinc,ou=GitHub,dc=github,dc=fake" + ou: "ou=kittensinc,ou=GitHub,dc=github,dc=fake", + ignore_not_found: false ) end @@ -148,6 +149,58 @@ result = subject.send(:add_user_to_organization, "bob", "admin") expect(result).to eq(false) end + + context "ignore_not_found is false" do + it "raises when user is not found" do + expect(logger).to receive(:debug).with("github.fake add_user_to_organization(user=bob, org=kittensinc, role=admin)") + expect(logger).to receive(:debug).with("Setting up GitHub API connection to https://github.fake/api/v3/") + + stub_request(:put, "https://github.fake/api/v3/orgs/kittensinc/memberships/bob").to_return( + status: 404, + headers: { + "Content-type" => "application/json" + }, + body: JSON.generate({ + "message" => "Not Found", + "documentation_url" => "https://docs.github.com/rest" + }) + ) + + expect { subject.send(:add_user_to_organization, "bob", "admin") }.to raise_error(Octokit::NotFound) + end + end + + context "ignore_not_found is true" do + let(:subject) do + described_class.new( + addr: "https://github.fake/api/v3", + org: "kittensinc", + token: "GoPackGo", + ou: "ou=kittensinc,ou=GitHub,dc=github,dc=fake", + ignore_not_found: true + ) + end + + it "ignores 404s" do + expect(logger).to receive(:debug).with("github.fake add_user_to_organization(user=bob, org=kittensinc, role=admin)") + expect(logger).to receive(:debug).with("Setting up GitHub API connection to https://github.fake/api/v3/") + expect(logger).to receive(:warn).with("User bob not found in GitHub instance github.fake, ignoring.") + + stub_request(:put, "https://github.fake/api/v3/orgs/kittensinc/memberships/bob").to_return( + status: 404, + headers: { + "Content-type" => "application/json" + }, + body: JSON.generate({ + "message" => "Not Found", + "documentation_url" => "https://docs.github.com/rest" + }) + ) + + result = subject.send(:add_user_to_organization, "bob", "admin") + expect(result).to eq(false) + end + end end end diff --git a/spec/unit/entitlements/backend/github_team/controller_spec.rb b/spec/unit/entitlements/backend/github_team/controller_spec.rb index 0bbd7a9..44a4869 100644 --- a/spec/unit/entitlements/backend/github_team/controller_spec.rb +++ b/spec/unit/entitlements/backend/github_team/controller_spec.rb @@ -11,7 +11,8 @@ "org" => "kittensinc", "token" => "CuteAndCuddlyKittens", "type" => "github_team", - "base" => "ou=kittensinc,ou=GitHub,dc=github,dc=com" + "base" => "ou=kittensinc,ou=GitHub,dc=github,dc=com", + "ignore_not_found" => false } end let(:group_name) { "foo-githubteam" } @@ -34,7 +35,7 @@ team_name: "russian-blues", ou: "ou=kittensinc,ou=GitHub,dc=github,dc=com", members: Set.new(%w[blackmanx MAINECOON]), - metadata: {"team_id" => 1001} + metadata: { "team_id" => 1001 } ) end @@ -43,7 +44,7 @@ dn: "cn=russian-blues,ou=kittensinc,ou=GitHub,dc=github,dc=fake", description: ":smile_cat:", members: Set.new(%w[blackmanx MAINECOON]), - metadata: {"team_id" => 1001} + metadata: { "team_id" => 1001 } ) end @@ -53,7 +54,7 @@ team_name: "snowshoes", ou: "ou=kittensinc,ou=GitHub,dc=github,dc=com", members: Set.new(%w[blackmanx MAINECOON]), - metadata: {"team_id" => 1002} + metadata: { "team_id" => 1002 } ) end @@ -62,7 +63,7 @@ dn: "cn=snowshoes,ou=kittensinc,ou=GitHub,dc=github,dc=fake", description: ":smile_cat:", members: Set.new(%w[blackmanx MAINECOON]), - metadata: {"team_id" => 1002} + metadata: { "team_id" => 1002 } ) end @@ -72,7 +73,7 @@ team_name: "chicken", ou: "ou=kittensinc,ou=GitHub,dc=github,dc=fake", members: Set.new(%w[blackmanx]), - metadata: {"team_id" => 10001} + metadata: { "team_id" => 10001 } ) end @@ -81,36 +82,36 @@ dn: "cn=chicken,ou=kittensinc,ou=GitHub,dc=github,dc=fake", description: ":smile_cat:", members: Set.new(%w[blackmanx]), - metadata: {"team_id" => 10001} + metadata: { "team_id" => 10001 } ) end - context "with changes" do - let(:russian_blue_group) do - Entitlements::Models::Group.new( - dn: "cn=russian-blues,ou=kittensinc,ou=GitHub,dc=github,dc=com", - members: Set.new(%w[RagaMuffin MAINECOON]), - metadata: { "team_id" => 1001 } - ) - end - - let(:snowshoe_group) do - Entitlements::Models::Group.new( - dn: "cn=snowshoes,ou=kittensinc,ou=GitHub,dc=github,dc=com", - members: Set.new(%w[blackmanx RagaMuffin MAINECOON]) - ) - end - - let(:chicken_group) do - Entitlements::Models::Group.new( - dn: "cn=chicken,ou=kittensinc,ou=GitHub,dc=github,dc=fake", - members: Set.new(%w[BlackManx highlander RUSSIANBLue]) - ) - end - - it "logs expected output and returns expected actions" do + context "with changes" do + let(:russian_blue_group) do + Entitlements::Models::Group.new( + dn: "cn=russian-blues,ou=kittensinc,ou=GitHub,dc=github,dc=com", + members: Set.new(%w[RagaMuffin MAINECOON]), + metadata: { "team_id" => 1001 } + ) + end + + let(:snowshoe_group) do + Entitlements::Models::Group.new( + dn: "cn=snowshoes,ou=kittensinc,ou=GitHub,dc=github,dc=com", + members: Set.new(%w[blackmanx RagaMuffin MAINECOON]) + ) + end + + let(:chicken_group) do + Entitlements::Models::Group.new( + dn: "cn=chicken,ou=kittensinc,ou=GitHub,dc=github,dc=fake", + members: Set.new(%w[BlackManx highlander RUSSIANBLue]) + ) + end + + it "logs expected output and returns expected actions" do allow(Entitlements::Data::Groups::Calculated).to receive(:read_all) - .with("foo-githubteam", {"base"=>"ou=kittensinc,ou=GitHub,dc=github,dc=com", "org"=>"kittensinc", "token"=>"CuteAndCuddlyKittens"}) + .with("foo-githubteam", { "base" => "ou=kittensinc,ou=GitHub,dc=github,dc=com", "org" => "kittensinc", "token" => "CuteAndCuddlyKittens", "ignore_not_found" => false }) .and_return(Set.new(%w[snowshoes russian-blues])) allow(Entitlements::Data::Groups::Calculated).to receive(:read).with("snowshoes").and_return(snowshoe_group) allow(Entitlements::Data::Groups::Calculated).to receive(:read).with("russian-blues").and_return(russian_blue_group) @@ -144,8 +145,8 @@ expect(result[1].dn).to eq("russian-blues") expect(result[1].existing.member_strings).to eq(Set.new(%w[blackmanx mainecoon])) expect(result[1].updated.member_strings).to eq(Set.new(%w[RagaMuffin MAINECOON])) - end - end + end + end context "with no changes" do let(:russian_blue_group) do @@ -158,7 +159,7 @@ it "does not run actions if there are no diffs detected" do allow(Entitlements::Data::Groups::Calculated).to receive(:read_all) - .with("foo-githubteam", {"base"=>"ou=kittensinc,ou=GitHub,dc=github,dc=com", "org"=>"kittensinc", "token"=>"CuteAndCuddlyKittens"}) + .with("foo-githubteam", { "base" => "ou=kittensinc,ou=GitHub,dc=github,dc=com", "org" => "kittensinc", "token" => "CuteAndCuddlyKittens", "ignore_not_found" => false }) .and_return(Set.new(%w[russian-blues])) allow(Entitlements::Data::Groups::Calculated).to receive(:read).with("russian-blues").and_return(russian_blue_group) allow(Entitlements::Util::Util).to receive(:dns_for_ou).with("foo-githubteam", anything).and_return([russian_blue_group.dn]) @@ -204,7 +205,7 @@ it "logs expected output and returns expected actions" do allow(Entitlements::Data::Groups::Calculated).to receive(:read_all) - .with("foo-githubteam", {"base"=>"ou=kittensinc,ou=GitHub,dc=github,dc=com", "org"=>"kittensinc", "token"=>"CuteAndCuddlyKittens"}) + .with("foo-githubteam", { "base" => "ou=kittensinc,ou=GitHub,dc=github,dc=com", "org" => "kittensinc", "token" => "CuteAndCuddlyKittens", "ignore_not_found" => false }) .and_return(Set.new(%w[snowshoes russian-blues])) allow(Entitlements::Data::Groups::Calculated).to receive(:read).with("snowshoes").and_return(snowshoe_group) allow(Entitlements::Data::Groups::Calculated).to receive(:read).with("russian-blues").and_return(russian_blue_group) diff --git a/spec/unit/entitlements/backend/github_team/provider_spec.rb b/spec/unit/entitlements/backend/github_team/provider_spec.rb index 83576c4..d3df3dc 100644 --- a/spec/unit/entitlements/backend/github_team/provider_spec.rb +++ b/spec/unit/entitlements/backend/github_team/provider_spec.rb @@ -7,7 +7,8 @@ addr: "https://github.fake/api/v3", org: "kittensinc", token: "GoPackGo", - ou: "ou=kittensinc,ou=GitHub,dc=github,dc=fake" + ou: "ou=kittensinc,ou=GitHub,dc=github,dc=fake", + ignore_not_found: false } end @@ -19,8 +20,8 @@ let(:russian_blue) { Entitlements::Models::Person.new(uid: "russian_blue") } let(:members) { Set.new(%w[SnowShoe russian_blue]) } let(:dn) { "cn=cats,ou=Github,dc=github,dc=fake" } - let(:group) { Entitlements::Models::Group.new(dn: dn, description: ":smile_cat:", members: Set.new([snowshoe, russian_blue]), metadata: {"application_owner" => "russian_blue"}) } - let(:team) { Entitlements::Backend::GitHubTeam::Models::Team.new(team_id: 1001, team_name: "cats", members: members, ou: "ou=kittensinc,ou=GitHub,dc=github,dc=fake", metadata: {"application_owner" => "russian_blue"}) } + let(:group) { Entitlements::Models::Group.new(dn:, description: ":smile_cat:", members: Set.new([snowshoe, russian_blue]), metadata: { "application_owner" => "russian_blue" }) } + let(:team) { Entitlements::Backend::GitHubTeam::Models::Team.new(team_id: 1001, team_name: "cats", members:, ou: "ou=kittensinc,ou=GitHub,dc=github,dc=fake", metadata: { "application_owner" => "russian_blue" }) } let(:member_strings_set) { Set.new(members.map(&:downcase)) } let(:subject) { described_class.new(config: provider_config) } @@ -50,7 +51,7 @@ end it "returns the additional metadata from the entitlement" do - metadata = {"application_owner" => "russian_blue"} + metadata = { "application_owner" => "russian_blue" } allow(subject).to receive(:github).and_return(github) expect(github).to receive(:read_team).with(entitlement_group_double).and_return(team) expect(logger).to receive(:debug).with("Loaded cn=cats,ou=kittensinc,ou=GitHub,dc=github,dc=fake (id=1001) with 2 member(s)") @@ -140,7 +141,7 @@ expect(logger).to receive(:debug).with("Loaded cn=grumpy-cats,ou=kittensinc,ou=GitHub,dc=github,dc=fake (id=1001) with 3 member(s)") allow(subject).to receive(:github).and_return(github) - expect(github).to receive(:graphql_team_data).with(team_identifier).and_return(members: old_members, team_id: 1001, parent_team_name: nil) + expect(github).to receive(:graphql_team_data).with(team_identifier).and_return(members: old_members, team_id: 1001, parent_team_name: nil, roles: Hash[*old_members.collect { |u| [u, "member"] }.flatten]) expect(subject.diff(group)).to eq(empty_result) end @@ -153,7 +154,7 @@ cache[:predictive_state] = { by_dn: { team_dn => { members: old_members, metadata: nil } }, invalid: Set.new } allow(subject).to receive(:github).and_return(github) - expect(github).to receive(:graphql_team_data).with(team_identifier).and_return(members: old_members, team_id: 1001, parent_team_name: nil) + expect(github).to receive(:graphql_team_data).with(team_identifier).and_return(members: old_members, team_id: 1001, parent_team_name: nil, roles: Hash[*old_members.collect { |u| [u, "member"] }.flatten]) expect(subject.diff(group)).to eq(added: Set.new(%w[mainecoon]), removed: Set.new(%w[russianblue])) end @@ -163,7 +164,7 @@ let(:new_members) { Set.new(%w[snowshoe russianblue]) } it "accurately computes changes" do - cache[:predictive_state] = { by_dn: { }, invalid: Set.new } + cache[:predictive_state] = { by_dn: {}, invalid: Set.new } entitlement_group = Entitlements::Models::Group.new( dn: "cn=diff-cats,ou=Github,dc=github,dc=fake", @@ -189,9 +190,9 @@ end describe "#change_ignored?" do - let(:group1) { Entitlements::Models::Group.new(dn: dn, description: ":smile_cat:", members: Set.new([snowshoe])) } - let(:group2) { Entitlements::Models::Group.new(dn: dn, description: ":smile_cat:", members: Set.new([russian_blue])) } - let(:action) { Entitlements::Models::Action.new(dn, group1, group2, "foo", ignored_users: ignored_users) } + let(:group1) { Entitlements::Models::Group.new(dn:, description: ":smile_cat:", members: Set.new([snowshoe])) } + let(:group2) { Entitlements::Models::Group.new(dn:, description: ":smile_cat:", members: Set.new([russian_blue])) } + let(:action) { Entitlements::Models::Action.new(dn, group1, group2, "foo", ignored_users:) } context "all adds/removes ignored" do let(:ignored_users) { Set.new([snowshoe, russian_blue].map(&:uid)) } @@ -260,7 +261,7 @@ allow(subject).to receive(:github).and_return(github) expect(github).to receive(:read_team).with(entitlement_group).and_return(nil) - expect(github).to receive(:create_team).with({entitlement_group: entitlement_group}).and_return(true) + expect(github).to receive(:create_team).with({ entitlement_group: }).and_return(true) expect(github).to receive(:invalidate_predictive_cache).with(entitlement_group).and_return(nil) expect(github).to receive(:read_team).with(entitlement_group).and_return(github_team) expect(github).to receive(:sync_team).with(entitlement_group, github_team).and_return(true) @@ -319,7 +320,7 @@ entitlements_group = Entitlements::Models::Group.new( dn: "cn=diff-cats,ou=Github,dc=github,dc=fake", members: Set.new(%w[cuddles fluffy morris WHISKERS].map { |u| "uid=#{u},ou=People,dc=kittens,dc=net" }), - metadata: { } + metadata: {} ) github_team = Entitlements::Backend::GitHubTeam::Models::Team.new( @@ -350,7 +351,7 @@ team_name: "diff-cats", members: Set.new(%w[cuddles fluffy morris WHISKERS].map { |u| "uid=#{u},ou=People,dc=kittens,dc=net" }), ou: "ou=kittensinc,ou=GitHub,dc=github,dc=fake", - metadata: { } + metadata: {} ) result = subject.diff_existing_updated(entitlements_group, github_team) @@ -360,6 +361,97 @@ metadata: { parent_team: "remove" } ) end + + it "diffs team maintainers change" do + entitlements_group = Entitlements::Models::Group.new( + dn: "cn=diff-cats,ou=Github,dc=github,dc=fake", + members: Set.new(%w[cuddles fluffy morris WHISKERS].map { |u| "uid=#{u},ou=People,dc=kittens,dc=net" }), + metadata: { "team_maintainers" => "cuddles" } + ) + + github_team = Entitlements::Backend::GitHubTeam::Models::Team.new( + team_id: 2222, + team_name: "diff-cats", + members: Set.new(%w[cuddles fluffy morris WHISKERS].map { |u| "uid=#{u},ou=People,dc=kittens,dc=net" }), + ou: "ou=kittensinc,ou=GitHub,dc=github,dc=fake", + metadata: { "team_maintainers" => "cuddles,fluffy" } + ) + + result = subject.diff_existing_updated(entitlements_group, github_team) + expect(result).to eq( + added: Set.new, + removed: Set.new, + metadata: { team_maintainers: "change" } + ) + end + + it "diffs team maintainers add" do + entitlements_group = Entitlements::Models::Group.new( + dn: "cn=diff-cats,ou=Github,dc=github,dc=fake", + members: Set.new(%w[cuddles fluffy morris WHISKERS].map { |u| "uid=#{u},ou=People,dc=kittens,dc=net" }), + metadata: {} + ) + + github_team = Entitlements::Backend::GitHubTeam::Models::Team.new( + team_id: 2222, + team_name: "diff-cats", + members: Set.new(%w[cuddles fluffy morris WHISKERS].map { |u| "uid=#{u},ou=People,dc=kittens,dc=net" }), + ou: "ou=kittensinc,ou=GitHub,dc=github,dc=fake", + metadata: { "team_maintainers" => "cuddles,fluffy" } + ) + + result = subject.diff_existing_updated(entitlements_group, github_team) + expect(result).to eq( + added: Set.new, + removed: Set.new, + metadata: { team_maintainers: "add" } + ) + end + + it "diffs team maintainers removal" do + entitlements_group = Entitlements::Models::Group.new( + dn: "cn=diff-cats,ou=Github,dc=github,dc=fake", + members: Set.new(%w[cuddles fluffy morris WHISKERS].map { |u| "uid=#{u},ou=People,dc=kittens,dc=net" }), + metadata: { "team_maintainers" => "cuddles,fluffy" } + ) + + github_team = Entitlements::Backend::GitHubTeam::Models::Team.new( + team_id: 2222, + team_name: "diff-cats", + members: Set.new(%w[cuddles fluffy morris WHISKERS].map { |u| "uid=#{u},ou=People,dc=kittens,dc=net" }), + ou: "ou=kittensinc,ou=GitHub,dc=github,dc=fake", + metadata: {} + ) + + result = subject.diff_existing_updated(entitlements_group, github_team) + expect(result).to eq( + added: Set.new, + removed: Set.new, + metadata: { team_maintainers: "remove" } + ) + end + + it "diffs team maintainers no change" do + entitlements_group = Entitlements::Models::Group.new( + dn: "cn=diff-cats,ou=Github,dc=github,dc=fake", + members: Set.new(%w[cuddles fluffy morris WHISKERS].map { |u| "uid=#{u},ou=People,dc=kittens,dc=net" }), + metadata: { "team_maintainers" => "cuddles,Fluffy" } + ) + + github_team = Entitlements::Backend::GitHubTeam::Models::Team.new( + team_id: 2222, + team_name: "diff-cats", + members: Set.new(%w[cuddles fluffy morris WHISKERS].map { |u| "uid=#{u},ou=People,dc=kittens,dc=net" }), + ou: "ou=kittensinc,ou=GitHub,dc=github,dc=fake", + metadata: { "team_maintainers" => "cuddles,fluffy" } + ) + + result = subject.diff_existing_updated(entitlements_group, github_team) + expect(result).to eq( + added: Set.new, + removed: Set.new, + ) + end end describe "#create_github_team_group" do @@ -367,7 +459,7 @@ entitlement_group = Entitlements::Models::Group.new( dn: "cn=new-kittens,ou=Github,dc=github,dc=fake", members: Set.new(%w[cuddles fluffy morris WHISKERS].map { |u| "uid=#{u},ou=People,dc=kittens,dc=net" }), - metadata: { } + metadata: {} ) github_team = Entitlements::Backend::GitHubTeam::Models::Team.new( @@ -375,7 +467,7 @@ team_name: "new-kittens", members: Set.new, ou: "ou=kittensinc,ou=GitHub,dc=github,dc=fake", - metadata: {"team_id" => -999} + metadata: { "team_id" => -999 } ) result = subject.send(:create_github_team_group, entitlement_group) @@ -394,7 +486,7 @@ team_name: "new-kittens", members: Set.new, ou: "ou=kittensinc,ou=GitHub,dc=github,dc=fake", - metadata: {"team_id" => -999} + metadata: { "team_id" => -999 } ) result = subject.send(:create_github_team_group, entitlement_group) diff --git a/spec/unit/entitlements/backend/github_team/service_spec.rb b/spec/unit/entitlements/backend/github_team/service_spec.rb index fc6c3d6..bc430af 100644 --- a/spec/unit/entitlements/backend/github_team/service_spec.rb +++ b/spec/unit/entitlements/backend/github_team/service_spec.rb @@ -11,7 +11,8 @@ addr: "https://github.fake/api/v3", org: "kittensinc", token: "GoPackGo", - ou: "ou=kittensinc,ou=GitHub,dc=github,dc=fake" + ou: "ou=kittensinc,ou=GitHub,dc=github,dc=fake", + ignore_not_found: false ) end @@ -20,7 +21,7 @@ dn: "cn=cuddly-kittens,ou=kittensinc,ou=GitHub,dc=github,dc=fake", description: ":smile_cat:", members: Set.new(%w[russian-blue snowshoe tabby siamese housecat tiger]), - metadata: {"application_owner" => "russian_blue"} + metadata: { "application_owner" => "russian_blue" } ) end @@ -29,7 +30,7 @@ dn: "cn=cuddly-kittens,ou=kittensinc,ou=GitHub,dc=github,dc=fake", description: ":smile_cat:", members: Set.new(%w[russian-blue snowshoe tabby siamese housecat tiger]), - metadata: {"parent_team_name" => "parent-cats"} + metadata: { "parent_team_name" => "parent-cats" } ) end @@ -53,17 +54,17 @@ let(:cuddly_kittens) do Entitlements::Backend::GitHubTeam::Models::Team.new( - team_id: 1234567, + team_id: 1_234_567, team_name: "cuddly-kittens", members: Set.new(%w[russian-blue snowshoe tabby siamese housecat tiger]), ou: "cuteness", - metadata: {"application_owner" => "russian_blue"} + metadata: { "application_owner" => "russian_blue" } ) end let(:cuddly_kittens_no_metadata) do Entitlements::Backend::GitHubTeam::Models::Team.new( - team_id: 1234567, + team_id: 1_234_567, team_name: "cuddly-kittens", members: Set.new(%w[russian-blue snowshoe tabby siamese housecat tiger]), ou: "cuteness", @@ -77,22 +78,22 @@ describe "#read_team" do it "returns nil when the team does not exist" do graphql_response = '{"data":{"organization":{"team":null}}}' - stub_request(:post, "https://github.fake/api/v3/graphql"). - with( - body: "{\"query\":\"{\\norganization(login: \\\"kittensinc\\\") {\\nteam(slug: \\\"team-does-not-exist\\\") {\\ndatabaseId\\nparentTeam {\\nslug\\n}\\nmembers(first: 100, membership: IMMEDIATE) {\\nedges {\\nnode {\\nlogin\\n}\\ncursor\\n}\\n}\\n}\\n}\\n}\"}" + stub_request(:post, "https://github.fake/api/v3/graphql") + .with( + body: "{\"query\":\"{\\norganization(login: \\\"kittensinc\\\") {\\nteam(slug: \\\"team-does-not-exist\\\") {\\ndatabaseId\\nparentTeam {\\nslug\\n}\\nmembers(first: 100, membership: IMMEDIATE) {\\nedges {\\nnode {\\nlogin\\n}\\nrole\\ncursor\\n}\\n}\\n}\\n}\\n}\"}" ).to_return(status: 200, body: graphql_response) expect(logger).to receive(:debug).with("Setting up GitHub API connection to https://github.fake/api/v3/") expect(logger).to receive(:debug).with("Loading GitHub team github.fake:kittensinc/team-does-not-exist") - expect(logger).to receive(:warn).with("Team team-does-not-exist does not exist in this GitHub.com organization") + expect(logger).to receive(:warn).with("Team team-does-not-exist does not exist in this GitHub.com organization. If applied, the team will be created.") result = subject.read_team(entitlement_group_doesnt_exist) expect(result).to eq(nil) end it "returns a Entitlements::Backend::GitHubTeam::Models::Team object when the team exists" do - stub_request(:post, "https://github.fake/api/v3/graphql"). - with( - body: "{\"query\":\"{\\norganization(login: \\\"kittensinc\\\") {\\nteam(slug: \\\"cuddly-kittens\\\") {\\ndatabaseId\\nparentTeam {\\nslug\\n}\\nmembers(first: 100, membership: IMMEDIATE) {\\nedges {\\nnode {\\nlogin\\n}\\ncursor\\n}\\n}\\n}\\n}\\n}\"}" + stub_request(:post, "https://github.fake/api/v3/graphql") + .with( + body: "{\"query\":\"{\\norganization(login: \\\"kittensinc\\\") {\\nteam(slug: \\\"cuddly-kittens\\\") {\\ndatabaseId\\nparentTeam {\\nslug\\n}\\nmembers(first: 100, membership: IMMEDIATE) {\\nedges {\\nnode {\\nlogin\\n}\\nrole\\ncursor\\n}\\n}\\n}\\n}\\n}\"}" ).to_return(status: 200, body: graphql_response(cuddly_kittens, 0, 100)) expect(logger).to receive(:debug).with("Setting up GitHub API connection to https://github.fake/api/v3/") @@ -107,9 +108,9 @@ end it "returns a Entitlements::Backend::GitHubTeam::Models::Team object with parent team when the team exists" do - stub_request(:post, "https://github.fake/api/v3/graphql"). - with( - body: "{\"query\":\"{\\norganization(login: \\\"kittensinc\\\") {\\nteam(slug: \\\"cuddly-kittens\\\") {\\ndatabaseId\\nparentTeam {\\nslug\\n}\\nmembers(first: 100, membership: IMMEDIATE) {\\nedges {\\nnode {\\nlogin\\n}\\ncursor\\n}\\n}\\n}\\n}\\n}\"}" + stub_request(:post, "https://github.fake/api/v3/graphql") + .with( + body: "{\"query\":\"{\\norganization(login: \\\"kittensinc\\\") {\\nteam(slug: \\\"cuddly-kittens\\\") {\\ndatabaseId\\nparentTeam {\\nslug\\n}\\nmembers(first: 100, membership: IMMEDIATE) {\\nedges {\\nnode {\\nlogin\\n}\\nrole\\ncursor\\n}\\n}\\n}\\n}\\n}\"}" ).to_return(status: 200, body: graphql_response(cuddly_kittens, 0, 100, parent_team: "parent-cats")) expect(logger).to receive(:debug).with("Setting up GitHub API connection to https://github.fake/api/v3/") @@ -126,9 +127,9 @@ end it "returns a Entitlements::Backend::GitHubTeam::Models::Team object with parent team when the team exists but has empty entitlement metadata" do - stub_request(:post, "https://github.fake/api/v3/graphql"). - with( - body: "{\"query\":\"{\\norganization(login: \\\"kittensinc\\\") {\\nteam(slug: \\\"cuddly-kittens\\\") {\\ndatabaseId\\nparentTeam {\\nslug\\n}\\nmembers(first: 100, membership: IMMEDIATE) {\\nedges {\\nnode {\\nlogin\\n}\\ncursor\\n}\\n}\\n}\\n}\\n}\"}" + stub_request(:post, "https://github.fake/api/v3/graphql") + .with( + body: "{\"query\":\"{\\norganization(login: \\\"kittensinc\\\") {\\nteam(slug: \\\"cuddly-kittens\\\") {\\ndatabaseId\\nparentTeam {\\nslug\\n}\\nmembers(first: 100, membership: IMMEDIATE) {\\nedges {\\nnode {\\nlogin\\n}\\nrole\\ncursor\\n}\\n}\\n}\\n}\\n}\"}" ).to_return(status: 200, body: graphql_response(cuddly_kittens_no_metadata, 0, 100, parent_team: "parent-cats")) expect(logger).to receive(:debug).with("Setting up GitHub API connection to https://github.fake/api/v3/") @@ -167,7 +168,8 @@ it "retrieves a value from the predictive cache" do people = Set.new(%w[blackmanx ragamuffin russianblue]) - Entitlements.cache[:predictive_state] = { by_ou: {}, by_dn: { team_dn => { members: people, metadata: nil } }, invalid: Set.new } + Entitlements.cache[:predictive_state] = + { by_ou: {}, by_dn: { team_dn => { members: people, metadata: nil } }, invalid: Set.new } expect(logger).to receive(:debug).with("Loading GitHub team github.fake:kittensinc/cuddly-kittens from cache") result = subject.read_team(entitlement_group_exists) @@ -178,9 +180,10 @@ end it "retrieves a value from the predictive cache with no entitlement metadata" do - people = Set.new(%w[blackmanx ragamuffin russianblue]) - Entitlements.cache[:predictive_state] = { by_ou: {}, by_dn: { team_dn => { members: people, metadata: { "parent_team_name" => "parent-cats" } } }, invalid: Set.new } + Entitlements.cache[:predictive_state] = + { by_ou: {}, by_dn: { team_dn => { members: people, metadata: { "parent_team_name" => "parent-cats" } } }, + invalid: Set.new } expect(logger).to receive(:debug).with("Loading GitHub team github.fake:kittensinc/cuddly-kittens from cache") result = subject.read_team(entitlement_group_exists_no_metadata) @@ -192,7 +195,9 @@ it "retrieves a value with metadata from the predictive cache" do people = Set.new(%w[blackmanx ragamuffin russianblue]) - Entitlements.cache[:predictive_state] = { by_ou: {}, by_dn: { team_dn => { members: people, metadata: { "application_owner" => "cheetoh" } } }, invalid: Set.new } + Entitlements.cache[:predictive_state] = + { by_ou: {}, by_dn: { team_dn => { members: people, metadata: { "application_owner" => "cheetoh" } } }, + invalid: Set.new } expect(logger).to receive(:debug).with("Loading GitHub team github.fake:kittensinc/cuddly-kittens from cache") result = subject.read_team(entitlement_group_exists) @@ -205,7 +210,9 @@ it "has value from the cache take precedence over value from the file" do people = Set.new(%w[blackmanx ragamuffin russianblue]) - Entitlements.cache[:predictive_state] = { by_ou: {}, by_dn: { team_dn => { members: people, metadata: { "application_owner" => "cheetoh" } } }, invalid: Set.new } + Entitlements.cache[:predictive_state] = + { by_ou: {}, by_dn: { team_dn => { members: people, metadata: { "application_owner" => "cheetoh" } } }, + invalid: Set.new } expect(logger).to receive(:debug).with("Loading GitHub team github.fake:kittensinc/cuddly-kittens from cache") result = subject.read_team(entitlement_group_exists) @@ -225,7 +232,9 @@ it "returns false" do cache[:predictive_state] = { by_dn: {}, invalid: Set.new } - expect(subject).to receive(:graphql_team_data).and_return(members: people.to_a, team_id: 1234567) + expect(subject).to receive(:graphql_team_data).and_return(members: people.to_a, team_id: 1_234_567, roles: Hash[*people.collect do |u| + [u, "member"] + end.flatten]) expect(logger).to receive(:debug).with("members(#{team_dn}): DN does not exist in cache") expect(logger).to receive(:debug).with("Loading GitHub team github.fake:kittensinc/cuddly-kittens") @@ -256,7 +265,9 @@ # Invalidating cache should force a re-read. people_2 = Set.new(people + %w[peterbald]) - expect(subject).to receive(:graphql_team_data).with(team_identifier).and_return(members: people_2.to_a, team_id: 1234567) + expect(subject).to receive(:graphql_team_data).with(team_identifier).and_return(members: people_2.to_a, team_id: 1_234_567, roles: Hash[*people_2.collect do |u| + [u, "member"] + end.flatten]) expect(logger).to receive(:debug).with("Invalidating cache entry for #{team_dn}") expect(logger).to receive(:debug).with("members(#{team_dn}): DN has been marked invalid in cache") expect(logger).to receive(:debug).with("Loading GitHub team github.fake:kittensinc/cuddly-kittens") @@ -265,18 +276,16 @@ # Check that the re-read has occurred and the correct result is achieved. expect(subject).not_to receive(:graphql_team_data) # Should already be in object's cache team_2 = subject.read_team(entitlement_group_exists) - expect(team_2.team_id).to eq(1234567) + expect(team_2.team_id).to eq(1_234_567) expect(team_2.member_strings).to eq(people_2) end end describe "#team_exists?" do it "returns true when the team can be read" do - end it "returns false when the team cannot be read" do - end end @@ -286,7 +295,7 @@ dn: "cn=russian-blues,ou=kittensinc,ou=GitHub,dc=github,dc=fake", description: ":smile_cat:", members: Set.new(%w[blackmanx ragamuffin MAINECOON]), - metadata: {"team_id" => 1001} + metadata: { "team_id" => 1001 } ) end @@ -340,6 +349,53 @@ ) end + let(:team_metadata_add_maintainer) do + Entitlements::Backend::GitHubTeam::Models::Team.new( + team_id: 1001, + team_name: "russian-blues", + members: Set.new(%w[blackmanx ragamuffin MAINECOON]), + ou: "ou=kittensinc,dc=github,dc=com", + metadata: { + "parent_team_name" => "cuddly-kittens", + "team_maintainers" => "blackmanx,ragamuffin" + } + ) + end + let(:team_metadata_maintainer_old) do + Entitlements::Backend::GitHubTeam::Models::Team.new( + team_id: 1001, + team_name: "russian-blues", + members: Set.new(%w[blackmanx ragamuffin MAINECOON]), + ou: "ou=kittensinc,dc=github,dc=com", + metadata: { + "parent_team_name" => "cuddly-kittens", + "team_maintainers" => "ragamuffin" + } + ) + end + let(:team_metadata_remove_all_maintainers) do + Entitlements::Backend::GitHubTeam::Models::Team.new( + team_id: 1001, + team_name: "russian-blues", + members: Set.new(%w[blackmanx ragamuffin MAINECOON]), + ou: "ou=kittensinc,dc=github,dc=com", + metadata: { + "parent_team_name" => "cuddly-kittens" + } + ) + end + let(:team_metadata_add_non_member_maintainer) do + Entitlements::Backend::GitHubTeam::Models::Team.new( + team_id: 1001, + team_name: "russian-blues", + members: Set.new(%w[blackmanx ragamuffin MAINECOON]), + ou: "ou=kittensinc,dc=github,dc=com", + metadata: { + "parent_team_name" => "cuddly-kittens", + "team_maintainers" => "krukow,ragamuffin" + } + ) + end let(:team_metadata_remove) do Entitlements::Backend::GitHubTeam::Models::Team.new( team_id: 1001, @@ -393,14 +449,53 @@ it "returns true when there were metadata changes" do allow(subject).to receive(:read_team).with(team_metadata_add).and_return(team_data_old) - expect(subject).to receive(:team_by_name).with(org_name: "kittensinc", team_name: "cuddly-kittens").and_return({ id: 10 }) - expect(subject).to receive(:update_team).with(team: team_metadata_add, metadata: { parent_team_id: 10 }).and_return(true) + expect(subject).to receive(:team_by_name).with(org_name: "kittensinc", + team_name: "cuddly-kittens").and_return({ id: 10 }) + expect(subject).to receive(:update_team).with(team: team_metadata_add, + metadata: { parent_team_id: 10 }).and_return(true) expect(logger).to receive(:debug).with(/sync_team\(russian-blues=1001\): Parent team change found - From No Parent Team to cuddly-kittens/) expect(logger).to receive(:debug).with(/sync_team\(russian-blues=1001\): Added 0, removed 0/) result = subject.sync_team(team_metadata_add, team_data_old) expect(result).to eq(true) end + it "returns true when there were metadata changes to add maintainer" do + allow(subject).to receive(:read_team).with(team_metadata_add_maintainer).and_return(team_metadata_maintainer_old) + expect(logger).to receive(:debug).with(/sync_team\(russian-blues=1001\): Maintainer members change found - From \["ragamuffin"\] to \["blackmanx", "ragamuffin"\]/) + expect(subject).to receive(:add_user_to_team).with(user: "blackmanx", team: team_metadata_maintainer_old, + role: "maintainer").and_return(true) + expect(logger).to receive(:debug).with(/sync_team\(russian-blues=1001\): Added 0, removed 0/) + result = subject.sync_team(team_metadata_add_maintainer, team_metadata_maintainer_old) + expect(result).to eq(true) + end + + it "returns false when there were metadata changes to remove ALL maintainers" do + allow(subject).to receive(:read_team).with(team_metadata_remove_all_maintainers).and_return(team_metadata_maintainer_old) + expect(logger).to receive(:debug).with(/sync_team\(russian-blues=1001\): IGNORING GitHub Team Maintainer DELETE/) + expect(logger).to receive(:debug).with(/sync_team\(russian-blues=1001\): Added 0, removed 0/) + result = subject.sync_team(team_metadata_remove_all_maintainers, team_metadata_maintainer_old) + expect(result).to eq(false) + end + + it "returns true when there were metadata changes to remove a maintainer" do + allow(subject).to receive(:read_team).with(team_metadata_maintainer_old).and_return(team_metadata_add_maintainer) + expect(logger).to receive(:debug).with(/sync_team\(russian-blues=1001\): Maintainer members change found - From \["blackmanx", "ragamuffin"\] to \["ragamuffin"\]/) + expect(subject).to receive(:add_user_to_team).with(user: "blackmanx", team: team_metadata_maintainer_old, + role: "member").and_return(true) + expect(logger).to receive(:debug).with(/sync_team\(russian-blues=1001\): Added 0, removed 0/) + result = subject.sync_team(team_metadata_maintainer_old, team_metadata_add_maintainer) + expect(result).to eq(true) + end + + it "returns false when there were metadata changes to add maintainer who is NOT in the team" do + allow(subject).to receive(:read_team).with(team_metadata_add_non_member_maintainer).and_return(team_metadata_maintainer_old) + expect(logger).to receive(:warn).with(/sync_team\(russian-blues=1001\): Maintainers must be a subset of team members. Desired maintainers: \["krukow"\] are not members. Ignoring./) + expect(logger).to receive(:debug).with(/sync_team\(russian-blues=1001\): Textual change but no semantic change in maintainers. It is remains: \["ragamuffin"\]./) + expect(logger).to receive(:debug).with(/sync_team\(russian-blues=1001\): Added 0, removed 0/) + result = subject.sync_team(team_metadata_add_non_member_maintainer, team_metadata_maintainer_old) + expect(result).to eq(false) + end + # TODO: I'm hard-coding a block for deletes, for now. I'm doing that by making sure we dont set the desired parent_team_id to nil for teams where it is already set it "returns false while deletes are prevented" do allow(subject).to receive(:read_team).with(team_metadata_add).and_return(team_metadata_remove) @@ -427,8 +522,8 @@ expect(subject).to receive(:org_members).and_return(Set.new(%w[blackmanx])) add_membership_response = { - "url" => "https://github.fake/api/v3/teams/1001/memberships/blackmanx", - "role" => "member", + "url" => "https://github.fake/api/v3/teams/1001/memberships/blackmanx", + "role" => "member", "state" => "active" } @@ -441,7 +536,7 @@ } ) - result = subject.send(:add_user_to_team, user: "blackmanx", team: team) + result = subject.send(:add_user_to_team, user: "blackmanx", team:) expect(result).to eq(true) end @@ -450,8 +545,8 @@ expect(subject).to receive(:org_members).and_return(Set.new(%w[blackmanx])) add_membership_response = { - "url" => "https://github.fake/api/v3/teams/1001/memberships/blackmanx", - "role" => "member", + "url" => "https://github.fake/api/v3/teams/1001/memberships/blackmanx", + "role" => "member", "state" => "pending" } @@ -464,7 +559,7 @@ } ) - result = subject.send(:add_user_to_team, user: "blackmanx", team: team) + result = subject.send(:add_user_to_team, user: "blackmanx", team:) expect(result).to eq(true) end @@ -473,8 +568,8 @@ expect(subject).to receive(:org_members).and_return(Set.new(%w[blackmanx])) add_membership_response = { - "url" => "https://github.fake/api/v3/teams/1001/memberships/blackmanx", - "role" => "member", + "url" => "https://github.fake/api/v3/teams/1001/memberships/blackmanx", + "role" => "member", "state" => "at chick-fil-a" } @@ -487,16 +582,81 @@ } ) - result = subject.send(:add_user_to_team, user: "blackmanx", team: team) + result = subject.send(:add_user_to_team, user: "blackmanx", team:) expect(result).to eq(false) end it "returns false when the user is ignored" do expect(subject).to receive(:org_members).and_return(Set.new(%w[ragamuffin])) - result = subject.send(:add_user_to_team, user: "blackmanx", team: team) + result = subject.send(:add_user_to_team, user: "blackmanx", team:) expect(result).to eq(false) end + + context "ignore_not_found is false" do + it "raises when user is not found" do + expect(subject).to receive(:validate_team_id_and_slug!).with(1001, "russian-blues").and_return(true) + expect(subject).to receive(:org_members).and_return(Set.new(%w[blackmanx])) + + add_membership_response = { + "url" => "https://github.fake/api/v3/teams/1001/memberships/blackmanx", + "role" => "member", + "state" => "active" + } + + stub_request(:put, "https://github.fake/api/v3/teams/1001/memberships/blackmanx") + .to_return( + status: 404, + headers: { + "Content-Type" => "application/json" + }, + body: JSON.generate({ + "message" => "Not Found", + "documentation_url" => "https://docs.github.com/rest" + }) + ) + + expect { subject.send(:add_user_to_team, user: "blackmanx", team:) }.to raise_error(Octokit::NotFound) + end + end + + context "ignore_not_found is true" do + let(:subject) do + described_class.new( + addr: "https://github.fake/api/v3", + org: "kittensinc", + token: "GoPackGo", + ou: "ou=kittensinc,ou=GitHub,dc=github,dc=fake", + ignore_not_found: true + ) + end + + it "ignores 404s" do + expect(subject).to receive(:validate_team_id_and_slug!).with(1001, "russian-blues").and_return(true) + expect(subject).to receive(:org_members).and_return(Set.new(%w[blackmanx])) + + add_membership_response = { + "url" => "https://github.fake/api/v3/teams/1001/memberships/blackmanx", + "role" => "member", + "state" => "active" + } + + stub_request(:put, "https://github.fake/api/v3/teams/1001/memberships/blackmanx") + .to_return( + status: 404, + headers: { + "Content-type" => "application/json" + }, + body: JSON.generate({ + "message" => "Not Found", + "documentation_url" => "https://docs.github.com/rest" + }) + ) + + result = subject.send(:add_user_to_team, user: "blackmanx", team:) + expect(result).to eq(false) + end + end end describe "#remove_user_from_team" do @@ -516,14 +676,14 @@ expect(subject).to receive(:validate_team_id_and_slug!).with(1001, "russian-blues").and_return(true) expect(subject).to receive(:org_members).and_return(Set.new(%w[blackmanx])) - result = subject.send(:remove_user_from_team, user: "blackmanx", team: team) + result = subject.send(:remove_user_from_team, user: "blackmanx", team:) expect(result).to eq(true) end it "returns false when the user is ignored" do expect(subject).to receive(:org_members).and_return(Set.new(%w[ragamuffin])) - result = subject.send(:remove_user_from_team, user: "blackmanx", team: team) + result = subject.send(:remove_user_from_team, user: "blackmanx", team:) expect(result).to eq(false) end end @@ -543,32 +703,37 @@ stub_request(:post, "https://github.fake/api/v3/graphql").to_return(status: 200, body: empty) expect do subject.send(:graphql_team_data, "crying-cat-face") - end.to raise_error(Entitlements::Backend::GitHubTeam::Service::TeamNotFound, "Requested team crying-cat-face does not exist in kittensinc!") + end.to raise_error(Entitlements::Backend::GitHubTeam::Service::TeamNotFound, + "Requested team crying-cat-face does not exist in kittensinc!") end end context "team found, single page of results" do let(:graphql_dotcom_response) do - <<-EOF -{"data":{"organization":{"team":{"databaseId":593721,"members":{"edges":[{"node":{"login":"highlander"},"cursor":"Y3Vyc29yOnYyOpHNNS0="},{"node":{"login":"blackmanx"},"cursor":"Y3Vyc29yOnYyOpHNTkI="},{"node":{"login":"toyger"},"cursor":"Y3Vyc29yOnYyOpHOAARtag=="},{"node":{"login":"ocicat"},"cursor":"Y3Vyc29yOnYyOpHOAAVi0w=="},{"node":{"login":"hubot"},"cursor":"Y3Vyc29yOnYyOpHOAAdWqg=="},{"node":{"login":"korat"},"cursor":"Y3Vyc29yOnYyOpHOABODaQ=="},{"node":{"login":"MAINECOON"},"cursor":"Y3Vyc29yOnYyOpHOAEIdJg=="},{"node":{"login":"russianblue"},"cursor":"Y3Vyc29yOnYyOpHOAEqWvg=="},{"node":{"login":"ragamuffin"},"cursor":"Y3Vyc29yOnYyOpHOAHgBJQ=="},{"node":{"login":"minskin"},"cursor":"Y3Vyc29yOnYyOpHOALafPw=="}]}}}}} + <<~EOF + {"data":{"organization":{"team":{"databaseId":593721,"members":{"edges":[{"node":{"login":"highlander"},"role":"MEMBER","cursor":"Y3Vyc29yOnYyOpHNNS0="},{"node":{"login":"blackmanx"},"role":"MEMBER","cursor":"Y3Vyc29yOnYyOpHNTkI="},{"node":{"login":"toyger"},"role":"MEMBER","cursor":"Y3Vyc29yOnYyOpHOAARtag=="},{"node":{"login":"ocicat"},"role":"MEMBER","cursor":"Y3Vyc29yOnYyOpHOAAVi0w=="},{"node":{"login":"hubot"},"role":"MEMBER","cursor":"Y3Vyc29yOnYyOpHOAAdWqg=="},{"node":{"login":"korat"},"role":"MEMBER","cursor":"Y3Vyc29yOnYyOpHOABODaQ=="},{"node":{"login":"MAINECOON"},"role":"MEMBER","cursor":"Y3Vyc29yOnYyOpHOAEIdJg=="},{"node":{"login":"russianblue"},"role":"MEMBER","cursor":"Y3Vyc29yOnYyOpHOAEqWvg=="},{"node":{"login":"ragamuffin"},"role":"MEMBER","cursor":"Y3Vyc29yOnYyOpHOAHgBJQ=="},{"node":{"login":"minskin"},"role":"MEMBER","cursor":"Y3Vyc29yOnYyOpHOALafPw=="}]}}}}} EOF end it "parses team data from a single page of results" do - stub_request(:post, "https://github.fake/api/v3/graphql"). - with( - body: "{\"query\":\"{\\norganization(login: \\\"kittensinc\\\") {\\nteam(slug: \\\"grumpy-cat\\\") {\\ndatabaseId\\nparentTeam {\\nslug\\n}\\nmembers(first: 100, membership: IMMEDIATE) {\\nedges {\\nnode {\\nlogin\\n}\\ncursor\\n}\\n}\\n}\\n}\\n}\"}", - headers: { - "Authorization" => "bearer GoPackGo", - "Content-Type" => "application/json", - }).to_return(status: 200, body: graphql_dotcom_response) - - result = subject.send(:graphql_team_data, "grumpy-cat") - expect(result).to eq( - members: ["highlander", "blackmanx", "toyger", "ocicat", "hubot", "korat", "mainecoon", "russianblue", "ragamuffin", "minskin"], - team_id: 593721, - parent_team_name: nil - ) + stub_request(:post, "https://github.fake/api/v3/graphql") + .with( + body: "{\"query\":\"{\\norganization(login: \\\"kittensinc\\\") {\\nteam(slug: \\\"grumpy-cat\\\") {\\ndatabaseId\\nparentTeam {\\nslug\\n}\\nmembers(first: 100, membership: IMMEDIATE) {\\nedges {\\nnode {\\nlogin\\n}\\nrole\\ncursor\\n}\\n}\\n}\\n}\\n}\"}", + headers: { + "Authorization" => "bearer GoPackGo", + "Content-Type" => "application/json" + } + ).to_return(status: 200, body: graphql_dotcom_response) + + result = subject.send(:graphql_team_data, "grumpy-cat") + members = ["highlander", "blackmanx", "toyger", "ocicat", "hubot", "korat", "mainecoon", "russianblue", + "ragamuffin", "minskin"] + expect(result).to eq( + members:, + team_id: 593_721, + parent_team_name: nil, + roles: Hash[*members.collect { |member| [member, "member"] }.flatten] + ) end end @@ -576,37 +741,40 @@ before(:each) { allow(subject).to receive(:max_graphql_results).and_return(4) } let(:graphql_dotcom_response_1) do - <<-EOF -{"data":{"organization":{"team":{"databaseId":593721,"members":{"edges":[{"node":{"login":"highlander"},"cursor":"Y3Vyc29yOnYyOpHNNS0="},{"node":{"login":"blackmanx"},"cursor":"Y3Vyc29yOnYyOpHNTkI="},{"node":{"login":"toyger"},"cursor":"Y3Vyc29yOnYyOpHOAARtag=="},{"node":{"login":"ocicat"},"cursor":"Y3Vyc29yOnYyOpHOAAVi0w=="}]}}}}} + <<~EOF + {"data":{"organization":{"team":{"databaseId":593721,"members":{"edges":[{"node":{"login":"highlander"},"role":"MEMBER","cursor":"Y3Vyc29yOnYyOpHNNS0="},{"node":{"login":"blackmanx"},"role":"MEMBER","cursor":"Y3Vyc29yOnYyOpHNTkI="},{"node":{"login":"toyger"},"role":"MEMBER","cursor":"Y3Vyc29yOnYyOpHOAARtag=="},{"node":{"login":"ocicat"},"role":"MEMBER","cursor":"Y3Vyc29yOnYyOpHOAAVi0w=="}]}}}}} EOF end let(:graphql_dotcom_response_2) do - <<-EOF -{"data":{"organization":{"team":{"databaseId":593721,"members":{"edges":[{"node":{"login":"hubot"},"cursor":"Y3Vyc29yOnYyOpHOAAdWqg=="},{"node":{"login":"korat"},"cursor":"Y3Vyc29yOnYyOpHOABODaQ=="},{"node":{"login":"MAINECOON"},"cursor":"Y3Vyc29yOnYyOpHOAEIdJg=="},{"node":{"login":"russianblue"},"cursor":"Y3Vyc29yOnYyOpHOAEqWvg=="}]}}}}} + <<~EOF + {"data":{"organization":{"team":{"databaseId":593721,"members":{"edges":[{"node":{"login":"hubot"},"role":"MEMBER","cursor":"Y3Vyc29yOnYyOpHOAAdWqg=="},{"node":{"login":"korat"},"role":"MEMBER","cursor":"Y3Vyc29yOnYyOpHOABODaQ=="},{"node":{"login":"MAINECOON"},"role":"MEMBER","cursor":"Y3Vyc29yOnYyOpHOAEIdJg=="},{"node":{"login":"russianblue"},"role":"MEMBER","cursor":"Y3Vyc29yOnYyOpHOAEqWvg=="}]}}}}} EOF end let(:graphql_dotcom_response_3) do - <<-EOF -{"data":{"organization":{"team":{"databaseId":593721,"members":{"edges":[{"node":{"login":"ragamuffin"},"cursor":"Y3Vyc29yOnYyOpHOAHgBJQ=="},{"node":{"login":"minskin"},"cursor":"Y3Vyc29yOnYyOpHOALafPw=="}]}}}}} + <<~EOF + {"data":{"organization":{"team":{"databaseId":593721,"members":{"edges":[{"node":{"login":"ragamuffin"},"role":"MEMBER","cursor":"Y3Vyc29yOnYyOpHOAHgBJQ=="},{"node":{"login":"minskin"},"role":"MEMBER","cursor":"Y3Vyc29yOnYyOpHOALafPw=="}]}}}}} EOF end it "parses team data from paginated results" do - stub_request(:post, "https://github.fake/api/v3/graphql"). - to_return( + stub_request(:post, "https://github.fake/api/v3/graphql") + .to_return( { status: 200, body: graphql_dotcom_response_1 }, { status: 200, body: graphql_dotcom_response_2 }, { status: 200, body: graphql_dotcom_response_3 } ) - result = subject.send(:graphql_team_data, "grumpy-cat") - expect(result).to eq( - members: ["highlander", "blackmanx", "toyger", "ocicat", "hubot", "korat", "mainecoon", "russianblue", "ragamuffin", "minskin"], - team_id: 593721, - parent_team_name: nil - ) + result = subject.send(:graphql_team_data, "grumpy-cat") + members = ["highlander", "blackmanx", "toyger", "ocicat", "hubot", "korat", "mainecoon", "russianblue", + "ragamuffin", "minskin"] + expect(result).to eq( + members:, + team_id: 593_721, + parent_team_name: nil, + roles: Hash[*members.collect { |member| [member, "member"] }.flatten] + ) end end @@ -614,37 +782,40 @@ before(:each) { allow(subject).to receive(:max_graphql_results).and_return(5) } let(:graphql_dotcom_response_1) do - <<-EOF -{"data":{"organization":{"team":{"databaseId":593721,"members":{"edges":[{"node":{"login":"highlander"},"cursor":"Y3Vyc29yOnYyOpHNNS0="},{"node":{"login":"blackmanx"},"cursor":"Y3Vyc29yOnYyOpHNTkI="},{"node":{"login":"toyger"},"cursor":"Y3Vyc29yOnYyOpHOAARtag=="},{"node":{"login":"ocicat"},"cursor":"Y3Vyc29yOnYyOpHOAAVi0w=="},{"node":{"login":"hubot"},"cursor":"Y3Vyc29yOnYyOpHOAAdWqg=="}]}}}}} + <<~EOF + {"data":{"organization":{"team":{"databaseId":593721,"members":{"edges":[{"node":{"login":"highlander"},"role":"MEMBER","cursor":"Y3Vyc29yOnYyOpHNNS0="},{"node":{"login":"blackmanx"},"role":"MEMBER","cursor":"Y3Vyc29yOnYyOpHNTkI="},{"node":{"login":"toyger"},"role":"MEMBER","cursor":"Y3Vyc29yOnYyOpHOAARtag=="},{"node":{"login":"ocicat"},"role":"MEMBER","cursor":"Y3Vyc29yOnYyOpHOAAVi0w=="},{"node":{"login":"hubot"},"role":"MEMBER","cursor":"Y3Vyc29yOnYyOpHOAAdWqg=="}]}}}}} EOF end let(:graphql_dotcom_response_2) do - <<-EOF -{"data":{"organization":{"team":{"databaseId":593721,"members":{"edges":[{"node":{"login":"korat"},"cursor":"Y3Vyc29yOnYyOpHOABODaQ=="},{"node":{"login":"MAINECOON"},"cursor":"Y3Vyc29yOnYyOpHOAEIdJg=="},{"node":{"login":"russianblue"},"cursor":"Y3Vyc29yOnYyOpHOAEqWvg=="},{"node":{"login":"ragamuffin"},"cursor":"Y3Vyc29yOnYyOpHOAHgBJQ=="},{"node":{"login":"minskin"},"cursor":"Y3Vyc29yOnYyOpHOALafPw=="}]}}}}} + <<~EOF + {"data":{"organization":{"team":{"databaseId":593721,"members":{"edges":[{"node":{"login":"korat"},"role":"MEMBER","cursor":"Y3Vyc29yOnYyOpHOABODaQ=="},{"node":{"login":"MAINECOON"},"role":"MEMBER","cursor":"Y3Vyc29yOnYyOpHOAEIdJg=="},{"node":{"login":"russianblue"},"role":"MEMBER","cursor":"Y3Vyc29yOnYyOpHOAEqWvg=="},{"node":{"login":"ragamuffin"},"role":"MEMBER","cursor":"Y3Vyc29yOnYyOpHOAHgBJQ=="},{"node":{"login":"minskin"},"role":"MEMBER","cursor":"Y3Vyc29yOnYyOpHOALafPw=="}]}}}}} EOF end let(:graphql_dotcom_response_3) do - <<-EOF -{"data":{"organization":{"team":{"databaseId":593721,"members":{"edges":[]}}}}} + <<~EOF + {"data":{"organization":{"team":{"databaseId":593721,"members":{"edges":[]}}}}} EOF end it "parses team data from paginated results" do - stub_request(:post, "https://github.fake/api/v3/graphql"). - to_return( + stub_request(:post, "https://github.fake/api/v3/graphql") + .to_return( { status: 200, body: graphql_dotcom_response_1 }, { status: 200, body: graphql_dotcom_response_2 }, { status: 200, body: graphql_dotcom_response_3 } ) - result = subject.send(:graphql_team_data, "grumpy-cat") - expect(result).to eq( - members: ["highlander", "blackmanx", "toyger", "ocicat", "hubot", "korat", "mainecoon", "russianblue", "ragamuffin", "minskin"], - team_id: 593721, - parent_team_name: nil - ) + result = subject.send(:graphql_team_data, "grumpy-cat") + members = ["highlander", "blackmanx", "toyger", "ocicat", "hubot", "korat", "mainecoon", "russianblue", + "ragamuffin", "minskin"] + expect(result).to eq( + members:, + team_id: 593_721, + parent_team_name: nil, + roles: Hash[*members.collect { |member| [member, "member"] }.flatten] + ) end end end @@ -667,13 +838,14 @@ expect do subject.send(:validate_team_id_and_slug!, 1234, "my-slug") - end.to raise_error(RuntimeError, 'validate_team_id_and_slug! mismatch: team_id=1234 expected="my-slug" got="some-other-slug"') + end.to raise_error(RuntimeError, + 'validate_team_id_and_slug! mismatch: team_id=1234 expected="my-slug" got="some-other-slug"') end it "does not handle octokit error" do exc = StandardError.new("Whoops!") allow(subject).to receive(:octokit).and_return(octokit) - expect(octokit).to receive(:team).with(1234).and_raise(exc) + expect(octokit).to receive(:team).with(1234).and_raise(exc).exactly(3).times expect do subject.send(:validate_team_id_and_slug!, 1234, "my-slug") @@ -687,8 +859,9 @@ it "creates a team" do octokit = instance_double(Octokit::Client) allow(subject).to receive(:octokit).and_return(octokit) - expect(octokit).to receive(:create_team).and_return(true) + expect(octokit).to receive(:create_team).and_return(id: 1_234_567).once expect(logger).to receive(:debug).with("create_team(team=cuddly-kittens)") + expect(logger).to receive(:debug).with("created team cuddly-kittens with id 1234567") created = subject.create_team(entitlement_group: entitlement_group_exists) expect(created).to eq(true) @@ -697,10 +870,30 @@ it "creates a team with a parent team" do octokit = instance_double(Octokit::Client) allow(subject).to receive(:octokit).and_return(octokit) - expect(octokit).to receive(:create_team).and_return(true) - expect(subject).to receive(:graphql_team_data).with("parent-cats").and_return(members: Set.new, team_id: 1234567) + expect(octokit).to receive(:create_team).and_return(id: 1_234_567).once + expect(subject).to receive(:graphql_team_data).with("parent-cats").and_return(members: Set.new, + team_id: 1_234_567) + expect(logger).to receive(:debug).with("create_team(team=cuddly-kittens) Parent team parent-cats with id 1234567 found") + expect(logger).to receive(:debug).with("create_team(team=cuddly-kittens)") + expect(logger).to receive(:debug).with("created team cuddly-kittens with id 1234567") + + created = subject.create_team(entitlement_group: entitlement_group_parent_team) + expect(created).to eq(true) + end + + it "creates a team and also the parent team when it is not found" do + octokit = instance_double(Octokit::Client) + allow(subject).to receive(:octokit).and_return(octokit) + expect(octokit).to receive(:create_team).and_return({ id: 1_234_567 }).once + expect(octokit).to receive(:create_team).and_return({ id: 1_234_555 }).once + expect(subject).to receive(:graphql_team_data) + .with("parent-cats") + .and_raise(Entitlements::Backend::GitHubTeam::Service::TeamNotFound) + + expect(logger).to receive(:debug).with("created parent team parent-cats with id 1234567") expect(logger).to receive(:debug).with("create_team(team=cuddly-kittens) Parent team parent-cats with id 1234567 found") expect(logger).to receive(:debug).with("create_team(team=cuddly-kittens)") + expect(logger).to receive(:debug).with("created team cuddly-kittens with id 1234555") created = subject.create_team(entitlement_group: entitlement_group_parent_team) expect(created).to eq(true) @@ -709,9 +902,10 @@ it "creates a team with empty metadata" do octokit = instance_double(Octokit::Client) allow(subject).to receive(:octokit).and_return(octokit) - expect(octokit).to receive(:create_team).and_return(true) + expect(octokit).to receive(:create_team).and_return(id: 1_234_567).once expect(logger).to receive(:debug).with("create_team(team=cuddly-kittens) No metadata found") expect(logger).to receive(:debug).with("create_team(team=cuddly-kittens)") + expect(logger).to receive(:debug).with("created team cuddly-kittens with id 1234567") created = subject.create_team(entitlement_group: entitlement_group_exists_no_metadata) expect(created).to eq(true) diff --git a/spec/unit/entitlements/service/github_spec.rb b/spec/unit/entitlements/service/github_spec.rb index 68c91ce..fe7b025 100644 --- a/spec/unit/entitlements/service/github_spec.rb +++ b/spec/unit/entitlements/service/github_spec.rb @@ -8,7 +8,8 @@ addr: "https://github.fake/api/v3", org: "kittensinc", token: "GoPackGo", - ou: "ou=kittensinc,ou=GitHub,dc=github,dc=fake" + ou: "ou=kittensinc,ou=GitHub,dc=github,dc=fake", + ignore_not_found: false ) end @@ -17,7 +18,8 @@ subject = described_class.new( org: "kittensinc", token: "GoPackGo", - ou: "ou=kittensinc,ou=GitHub,dc=github,dc=fake" + ou: "ou=kittensinc,ou=GitHub,dc=github,dc=fake", + ignore_not_found: false ) expect(subject.identifier).to eq("github.com") end @@ -99,7 +101,7 @@ context "when sourced from the cache" do it "returns true" do - cache[:predictive_state] = { by_dn: { admin_dn => { members: admins, metadata: nil }, member_dn => { members: members, metadata: nil } }, invalid: Set.new } + cache[:predictive_state] = { by_dn: { admin_dn => { members: admins, metadata: nil }, member_dn => { members:, metadata: nil } }, invalid: Set.new } expect(subject).not_to receive(:members_and_roles_from_rest) @@ -116,7 +118,7 @@ let(:answer) { { "monalisa" => "ADMIN", "ocicat" => "MEMBER", "blackmanx" => "MEMBER", "toyger" => "MEMBER" } } it "invaliates the cache" do - cache[:predictive_state] = { by_dn: { admin_dn => { members: admins, metadata: nil }, member_dn => { members: members, metadata: nil } }, invalid: Set.new } + cache[:predictive_state] = { by_dn: { admin_dn => { members: admins, metadata: nil }, member_dn => { members:, metadata: nil } }, invalid: Set.new } # First load should read from the cache. expect(subject.org_members).to eq(answer.map { |k, v| [k, v.downcase] }.to_h) @@ -193,7 +195,7 @@ to_return(status: 200, body: File.read(fixture("graphql-output/organization-members-page4.json"))) result = subject.send(:members_and_roles_from_graphql) - expect(result).to eq({"ocicat"=>"MEMBER", "blackmanx"=>"MEMBER", "toyger"=>"MEMBER", "highlander"=>"MEMBER", "russianblue"=>"MEMBER", "ragamuffin"=>"MEMBER", "monalisa"=>"ADMIN", "peterbald"=>"MEMBER", "mainecoon"=>"MEMBER", "laperm"=>"MEMBER"}) + expect(result).to eq({ "ocicat" => "MEMBER", "blackmanx" => "MEMBER", "toyger" => "MEMBER", "highlander" => "MEMBER", "russianblue" => "MEMBER", "ragamuffin" => "MEMBER", "monalisa" => "ADMIN", "peterbald" => "MEMBER", "mainecoon" => "MEMBER", "laperm" => "MEMBER" }) end end @@ -214,11 +216,59 @@ it "returns the expected hash" do expect(subject).to receive(:octokit).and_return(octokit).twice - expect(octokit).to receive(:organization_members).with("kittensinc", { role: "admin" }).and_return(admins.map { |login| { login: login } }) - expect(octokit).to receive(:organization_members).with("kittensinc", { role: "member" }).and_return(members.map { |login| { login: login } }) + expect(octokit).to receive(:organization_members).with("kittensinc", { role: "admin" }).and_return(admins.map { |login| { login: } }) + expect(octokit).to receive(:organization_members).with("kittensinc", { role: "member" }).and_return(members.map { |login| { login: } }) result = subject.send(:members_and_roles_from_rest) - expect(result).to eq({"ocicat"=>"MEMBER", "blackmanx"=>"MEMBER", "toyger"=>"MEMBER", "highlander"=>"MEMBER", "russianblue"=>"MEMBER", "ragamuffin"=>"MEMBER", "monalisa"=>"ADMIN", "peterbald"=>"MEMBER", "mainecoon"=>"MEMBER", "laperm"=>"MEMBER"}) + expect(result).to eq({ "ocicat" => "MEMBER", "blackmanx" => "MEMBER", "toyger" => "MEMBER", "highlander" => "MEMBER", "russianblue" => "MEMBER", "ragamuffin" => "MEMBER", "monalisa" => "ADMIN", "peterbald" => "MEMBER", "mainecoon" => "MEMBER", "laperm" => "MEMBER" }) + end + end + + context "when there are no admins" do + let(:members) { %w[ocicat blackmanx] } + let(:octokit) { instance_double(Octokit::Client) } + it "returns only members" do + expect(subject).to receive(:octokit).and_return(octokit).twice + expect(octokit).to receive(:organization_members).with("kittensinc", { role: "admin" }).and_return([]) + expect(octokit).to receive(:organization_members).with("kittensinc", { role: "member" }).and_return(members.map { |login| { login: } }) + result = subject.send(:members_and_roles_from_rest) + expect(result).to eq({ "ocicat" => "MEMBER", "blackmanx" => "MEMBER" }) + end + end + + context "when there are no members" do + let(:admins) { %w[monalisa] } + let(:octokit) { instance_double(Octokit::Client) } + it "returns only admins" do + expect(subject).to receive(:octokit).and_return(octokit).twice + expect(octokit).to receive(:organization_members).with("kittensinc", { role: "admin" }).and_return(admins.map { |login| { login: } }) + expect(octokit).to receive(:organization_members).with("kittensinc", { role: "member" }).and_return([]) + result = subject.send(:members_and_roles_from_rest) + expect(result).to eq({ "monalisa" => "ADMIN" }) + end + end + + context "when usernames have different cases" do + let(:admins) { ["Monalisa"] } + let(:members) { ["OCICAT", "BlackManx"] } + let(:octokit) { instance_double(Octokit::Client) } + it "downcases all usernames" do + expect(subject).to receive(:octokit).and_return(octokit).twice + expect(octokit).to receive(:organization_members).with("kittensinc", { role: "admin" }).and_return(admins.map { |login| { login: } }) + expect(octokit).to receive(:organization_members).with("kittensinc", { role: "member" }).and_return(members.map { |login| { login: } }) + result = subject.send(:members_and_roles_from_rest) + expect(result).to eq({ "monalisa" => "ADMIN", "ocicat" => "MEMBER", "blackmanx" => "MEMBER" }) + end + end + + context "when organization is empty" do + let(:octokit) { instance_double(Octokit::Client) } + it "returns an empty hash" do + expect(subject).to receive(:octokit).and_return(octokit).twice + expect(octokit).to receive(:organization_members).with("kittensinc", { role: "admin" }).and_return([]) + expect(octokit).to receive(:organization_members).with("kittensinc", { role: "member" }).and_return([]) + result = subject.send(:members_and_roles_from_rest) + expect(result).to eq({}) end end end @@ -338,7 +388,7 @@ it "logs and returns raw text for JSON parsing error" do answer = "mor chicken mor rewardz!" stub_request(:post, "https://github.fake/api/v3/graphql").to_return(status: 200, body: answer) - expect(logger).to receive(:error).with(/JSON::ParserError \d+: unexpected token at 'mor chicken mor rewardz!': \"mor chicken mor rewardz!\"/) + expect(logger).to receive(:error).with("JSON::ParserError unexpected character: 'mor' at line 1 column 1: \"mor chicken mor rewardz!\"") response = subject.send(:graphql_http_post_real, "nonsense") expect(response).to eq(code: 500, data: { "body" => "mor chicken mor rewardz!" }) end diff --git a/spec/unit/entitlements_spec.rb b/spec/unit/entitlements_spec.rb index c6a8ec1..1c272fb 100644 --- a/spec/unit/entitlements_spec.rb +++ b/spec/unit/entitlements_spec.rb @@ -101,9 +101,9 @@ it "calls Entitlements::Data::Groups::Calculated with appropriate arguments" do expect(Entitlements::Data::Groups::Calculated).to receive(:register_filter) - .with("filter1", class: Entitlements::Data::Groups::Calculated::Filters::MemberOfGroup, config: { "foo" => "bar" }) + .with("filter1", { class: Entitlements::Data::Groups::Calculated::Filters::MemberOfGroup, config: { "foo" => "bar" } }) expect(Entitlements::Data::Groups::Calculated).to receive(:register_filter) - .with("filter2", class: Entitlements::Extras::LDAPGroup::Filters::MemberOfLDAPGroup, config: {}) + .with("filter2", { class: Entitlements::Extras::LDAPGroup::Filters::MemberOfLDAPGroup, config: {} }) expect(logger).to receive(:debug).with("Registering filter filter1 (class: Entitlements::Data::Groups::Calculated::Filters::MemberOfGroup)") expect(logger).to receive(:debug).with("Registering filter filter2 (class: Entitlements::Extras::LDAPGroup::Filters::MemberOfLDAPGroup)") described_class.register_filters @@ -260,7 +260,7 @@ expect(logger).to receive(:debug).with("Audit Auditor 1 completed successfully") expect(logger).to receive(:debug).with("Audit Auditor 2 completed successfully") - expect { described_class.execute(actions: actions) }.not_to raise_error + expect { described_class.execute(actions:) }.not_to raise_error end it "returns without error with no auditors configured" do @@ -279,7 +279,7 @@ expect(logger).not_to receive(:debug) - expect { described_class.execute(actions: actions) }.not_to raise_error + expect { described_class.execute(actions:) }.not_to raise_error end it "raises when setup of an auditor fails" do @@ -293,7 +293,7 @@ expect(auditor2).not_to receive(:setup) expect(auditor2).not_to receive(:commit) - expect { described_class.execute(actions: actions) }.to raise_error(exc) + expect { described_class.execute(actions:) }.to raise_error(exc) end it "raises (but runs other auditors) when an auditor fails" do @@ -333,7 +333,7 @@ allow(logger).to receive(:error) expect(logger).to receive(:error).with("Audit Auditor 1 failed: RuntimeError Boom") - expect { described_class.execute(actions: actions) }.to raise_error(exc) + expect { described_class.execute(actions:) }.to raise_error(exc) end it "raises when a provider fails and there are no auditors" do @@ -353,7 +353,7 @@ expect(logger).not_to receive(:debug) - expect { described_class.execute(actions: actions) }.to raise_error(exc) + expect { described_class.execute(actions:) }.to raise_error(exc) end it "raises (but runs the auditors) when a provider fails" do @@ -392,7 +392,7 @@ expect(logger).to receive(:debug).with("Audit Auditor 1 completed successfully") expect(logger).to receive(:debug).with("Audit Auditor 2 completed successfully") - expect { described_class.execute(actions: actions) }.to raise_error(exc) + expect { described_class.execute(actions:) }.to raise_error(exc) end it "raises the provider's exception when a provider and auditor both fail" do @@ -432,7 +432,7 @@ expect(logger).to receive(:debug).with("Audit Auditor 2 completed successfully") allow(logger).to receive(:error) # Stack trace - expect { described_class.execute(actions: actions) }.to raise_error(exc) + expect { described_class.execute(actions:) }.to raise_error(exc) end it "raises and logs a message when multiple auditors fail" do @@ -473,7 +473,7 @@ expect(logger).to receive(:error).with("Audit Auditor 2 failed: RuntimeError Boom Boom") allow(logger).to receive(:error) # Stack trace - expect { described_class.execute(actions: actions) }.to raise_error(exc) + expect { described_class.execute(actions:) }.to raise_error(exc) end end diff --git a/spec/unit/fixtures/graphql-output/organization-members-page1.json b/spec/unit/fixtures/graphql-output/organization-members-page1.json index 22e4150..562f988 100644 --- a/spec/unit/fixtures/graphql-output/organization-members-page1.json +++ b/spec/unit/fixtures/graphql-output/organization-members-page1.json @@ -7,24 +7,24 @@ "node": { "login": "monalisa" }, - "role": "ADMIN", - "cursor": "Y3Vyc29yOnYyOpEB" + "role": "ADMIN" }, { "node": { "login": "ocicat" }, - "role": "MEMBER", - "cursor": "Y3Vyc29yOnYyOpEF" + "role": "MEMBER" }, { "node": { "login": "blackmanx" }, - "role": "MEMBER", - "cursor": "Y3Vyc29yOnYyOpEG" + "role": "MEMBER" } - ] + ], + "pageInfo": { + "endCursor": "Y3Vyc29yOnYyOpEG" + } } } } diff --git a/spec/unit/fixtures/graphql-output/organization-members-page2.json b/spec/unit/fixtures/graphql-output/organization-members-page2.json index b7d5ce9..be03e12 100644 --- a/spec/unit/fixtures/graphql-output/organization-members-page2.json +++ b/spec/unit/fixtures/graphql-output/organization-members-page2.json @@ -7,24 +7,24 @@ "node": { "login": "toyger" }, - "role": "MEMBER", - "cursor": "Y3Vyc29yOnYyOpEH" + "role": "MEMBER" }, { "node": { "login": "highlander" }, - "role": "MEMBER", - "cursor": "Y3Vyc29yOnYyOpEI" + "role": "MEMBER" }, { "node": { "login": "RussianBlue" }, - "role": "MEMBER", - "cursor": "Y3Vyc29yOnYyOpEJ" + "role": "MEMBER" } - ] + ], + "pageInfo": { + "endCursor": "Y3Vyc29yOnYyOpEJ" + } } } } diff --git a/spec/unit/fixtures/graphql-output/organization-members-page3.json b/spec/unit/fixtures/graphql-output/organization-members-page3.json index 0280049..49316ce 100644 --- a/spec/unit/fixtures/graphql-output/organization-members-page3.json +++ b/spec/unit/fixtures/graphql-output/organization-members-page3.json @@ -7,24 +7,24 @@ "node": { "login": "ragamuffin" }, - "role": "MEMBER", - "cursor": "Y3Vyc29yOnYyOpEK" + "role": "MEMBER" }, { "node": { "login": "mainecoon" }, - "role": "MEMBER", - "cursor": "Y3Vyc29yOnYyOpEL" + "role": "MEMBER" }, { "node": { "login": "laperm" }, - "role": "MEMBER", - "cursor": "Y3Vyc29yOnYyOpEM" + "role": "MEMBER" } - ] + ], + "pageInfo": { + "endCursor": "Y3Vyc29yOnYyOpEM" + } } } } diff --git a/spec/unit/fixtures/graphql-output/organization-members-page4.json b/spec/unit/fixtures/graphql-output/organization-members-page4.json index f5e6510..ac263d2 100644 --- a/spec/unit/fixtures/graphql-output/organization-members-page4.json +++ b/spec/unit/fixtures/graphql-output/organization-members-page4.json @@ -7,10 +7,12 @@ "node": { "login": "peterbald" }, - "role": "MEMBER", - "cursor": "Y3Vyc29yOnYyOpEN" + "role": "MEMBER" } - ] + ], + "pageInfo": { + "endCursor": "Y3Vyc29yOnYyOpEN" + } } } } diff --git a/spec/unit/fixtures/graphql-output/pending-members-page1.json b/spec/unit/fixtures/graphql-output/pending-members-page1.json index 76bd191..01d65c3 100644 --- a/spec/unit/fixtures/graphql-output/pending-members-page1.json +++ b/spec/unit/fixtures/graphql-output/pending-members-page1.json @@ -6,22 +6,22 @@ { "node": { "login": "alice" - }, - "cursor": "Y3Vyc29yOnYyOpEB" + } }, { "node": { "login": "bob" - }, - "cursor": "Y3Vyc29yOnYyOpEF" + } }, { "node": { "login": "charles" - }, - "cursor": "Y3Vyc29yOnYyOpEG" } - ] + } + ], + "pageInfo": { + "endCursor": "Y3Vyc29yOnYyOpEG" + } } } } diff --git a/spec/unit/fixtures/graphql-output/pending-members-page2.json b/spec/unit/fixtures/graphql-output/pending-members-page2.json index 33fc8cc..63c8335 100644 --- a/spec/unit/fixtures/graphql-output/pending-members-page2.json +++ b/spec/unit/fixtures/graphql-output/pending-members-page2.json @@ -6,22 +6,22 @@ { "node": { "login": "DAVID" - }, - "cursor": "Y3Vyc29yOnYyOpEH" + } }, { "node": { "login": "edward" - }, - "cursor": "Y3Vyc29yOnYyOpEI" + } }, { "node": { "login": "frank" - }, - "cursor": "Y3Vyc29yOnYyOpEJ" + } } - ] + ], + "pageInfo": { + "endCursor": "Y3Vyc29yOnYyOpEJ" + } } } } diff --git a/spec/unit/fixtures/graphql-output/pending-members-page3.json b/spec/unit/fixtures/graphql-output/pending-members-page3.json index 92b1ea7..74c7887 100644 --- a/spec/unit/fixtures/graphql-output/pending-members-page3.json +++ b/spec/unit/fixtures/graphql-output/pending-members-page3.json @@ -6,22 +6,22 @@ { "node": { "login": "george" - }, - "cursor": "Y3Vyc29yOnYyOpEK" + } }, { "node": { "login": "harriet" - }, - "cursor": "Y3Vyc29yOnYyOpEL" + } }, { "node": { "login": "ingrid" - }, - "cursor": "Y3Vyc29yOnYyOpEM" + } } - ] + ], + "pageInfo": { + "endCursor": "Y3Vyc29yOnYyOpEM" + } } } } diff --git a/spec/unit/fixtures/graphql-output/pending-members-page4.json b/spec/unit/fixtures/graphql-output/pending-members-page4.json index 79e9ab1..4095a30 100644 --- a/spec/unit/fixtures/graphql-output/pending-members-page4.json +++ b/spec/unit/fixtures/graphql-output/pending-members-page4.json @@ -6,10 +6,12 @@ { "node": { "login": "blackmanx" - }, - "cursor": "Y3Vyc29yOnYyOpEN" + } } - ] + ], + "pageInfo": { + "endCursor": "Y3Vyc29yOnYyOpEN" + } } } } diff --git a/spec/unit/spec_helper.rb b/spec/unit/spec_helper.rb index 1cfbc32..2a4ee2a 100644 --- a/spec/unit/spec_helper.rb +++ b/spec/unit/spec_helper.rb @@ -3,10 +3,23 @@ require "simplecov" require "simplecov-erb" +COV_DIR = File.expand_path("../../coverage", File.dirname(__FILE__)) + +SimpleCov.root File.expand_path("../../", File.dirname(__FILE__)) +SimpleCov.coverage_dir COV_DIR + SimpleCov.formatters = [ SimpleCov::Formatter::HTMLFormatter, SimpleCov::Formatter::ERBFormatter ] + +SimpleCov.minimum_coverage 100 + +SimpleCov.at_exit do + File.write("#{COV_DIR}/total-coverage.txt", SimpleCov.result.covered_percent) + SimpleCov.result.format! +end + SimpleCov.start do # don't show specs as missing coverage for themselves add_filter "/spec/" @@ -46,7 +59,7 @@ def default_filters def graphql_response(team, slice_start, slice_length, parent_team: nil) team_id = rand(1..10000) edges = team.member_strings.sort.to_a.slice(slice_start, slice_length).map do |m| - { "node" => { "login" => m }, "cursor" => Base64.encode64(m) } + { "node" => { "login" => m }, "role" => "MEMBER", "cursor" => Base64.encode64(m) } end struct = { "data" => { @@ -171,6 +184,11 @@ def instance_double(klass, *args) config.before :each do allow(Time).to receive(:now).and_return(Time.utc(2018, 4, 1, 12, 0, 0)) + + allow(Kernel).to receive(:sleep) + allow_any_instance_of(Kernel).to receive(:sleep) + allow_any_instance_of(Object).to receive(:sleep) + allow(Entitlements).to receive(:cache).and_return(cache) if entitlements_config_hash Entitlements.config = entitlements_config_hash diff --git a/vendor/cache/activesupport-7.0.3.1.gem b/vendor/cache/activesupport-7.0.3.1.gem deleted file mode 100644 index 0c3757a..0000000 Binary files a/vendor/cache/activesupport-7.0.3.1.gem and /dev/null differ diff --git a/vendor/cache/activesupport-7.1.5.1.gem b/vendor/cache/activesupport-7.1.5.1.gem new file mode 100644 index 0000000..449282f Binary files /dev/null and b/vendor/cache/activesupport-7.1.5.1.gem differ diff --git a/vendor/cache/addressable-2.8.1.gem b/vendor/cache/addressable-2.8.1.gem deleted file mode 100644 index 17e4257..0000000 Binary files a/vendor/cache/addressable-2.8.1.gem and /dev/null differ diff --git a/vendor/cache/addressable-2.8.9.gem b/vendor/cache/addressable-2.8.9.gem new file mode 100644 index 0000000..dd5ba13 Binary files /dev/null and b/vendor/cache/addressable-2.8.9.gem differ diff --git a/vendor/cache/ast-2.4.2.gem b/vendor/cache/ast-2.4.2.gem deleted file mode 100644 index abe1643..0000000 Binary files a/vendor/cache/ast-2.4.2.gem and /dev/null differ diff --git a/vendor/cache/ast-2.4.3.gem b/vendor/cache/ast-2.4.3.gem new file mode 100644 index 0000000..1f5e5c2 Binary files /dev/null and b/vendor/cache/ast-2.4.3.gem differ diff --git a/vendor/cache/base64-0.3.0.gem b/vendor/cache/base64-0.3.0.gem new file mode 100644 index 0000000..12f53f1 Binary files /dev/null and b/vendor/cache/base64-0.3.0.gem differ diff --git a/vendor/cache/benchmark-0.4.1.gem b/vendor/cache/benchmark-0.4.1.gem new file mode 100644 index 0000000..90cd272 Binary files /dev/null and b/vendor/cache/benchmark-0.4.1.gem differ diff --git a/vendor/cache/bigdecimal-4.0.1.gem b/vendor/cache/bigdecimal-4.0.1.gem new file mode 100644 index 0000000..598e339 Binary files /dev/null and b/vendor/cache/bigdecimal-4.0.1.gem differ diff --git a/vendor/cache/concurrent-ruby-1.1.9.gem b/vendor/cache/concurrent-ruby-1.1.9.gem deleted file mode 100644 index 9ed64f2..0000000 Binary files a/vendor/cache/concurrent-ruby-1.1.9.gem and /dev/null differ diff --git a/vendor/cache/concurrent-ruby-1.3.5.gem b/vendor/cache/concurrent-ruby-1.3.5.gem new file mode 100644 index 0000000..1cd9f52 Binary files /dev/null and b/vendor/cache/concurrent-ruby-1.3.5.gem differ diff --git a/vendor/cache/connection_pool-2.5.3.gem b/vendor/cache/connection_pool-2.5.3.gem new file mode 100644 index 0000000..23c398f Binary files /dev/null and b/vendor/cache/connection_pool-2.5.3.gem differ diff --git a/vendor/cache/contracts-0.17.3.gem b/vendor/cache/contracts-0.17.3.gem new file mode 100644 index 0000000..39ede9f Binary files /dev/null and b/vendor/cache/contracts-0.17.3.gem differ diff --git a/vendor/cache/contracts-0.17.gem b/vendor/cache/contracts-0.17.gem deleted file mode 100644 index d7ed5a4..0000000 Binary files a/vendor/cache/contracts-0.17.gem and /dev/null differ diff --git a/vendor/cache/crack-0.4.5.gem b/vendor/cache/crack-0.4.5.gem deleted file mode 100644 index d622be1..0000000 Binary files a/vendor/cache/crack-0.4.5.gem and /dev/null differ diff --git a/vendor/cache/crack-1.0.1.gem b/vendor/cache/crack-1.0.1.gem new file mode 100644 index 0000000..49807f7 Binary files /dev/null and b/vendor/cache/crack-1.0.1.gem differ diff --git a/vendor/cache/diff-lcs-1.5.0.gem b/vendor/cache/diff-lcs-1.5.0.gem deleted file mode 100644 index 3a25852..0000000 Binary files a/vendor/cache/diff-lcs-1.5.0.gem and /dev/null differ diff --git a/vendor/cache/diff-lcs-1.6.2.gem b/vendor/cache/diff-lcs-1.6.2.gem new file mode 100644 index 0000000..21c4c77 Binary files /dev/null and b/vendor/cache/diff-lcs-1.6.2.gem differ diff --git a/vendor/cache/drb-2.2.3.gem b/vendor/cache/drb-2.2.3.gem new file mode 100644 index 0000000..0c78b28 Binary files /dev/null and b/vendor/cache/drb-2.2.3.gem differ diff --git a/vendor/cache/entitlements-0.2.0.gem b/vendor/cache/entitlements-0.2.0.gem deleted file mode 100644 index 642a2e3..0000000 Binary files a/vendor/cache/entitlements-0.2.0.gem and /dev/null differ diff --git a/vendor/cache/entitlements-app-1.2.1.gem b/vendor/cache/entitlements-app-1.2.1.gem new file mode 100644 index 0000000..e762ca8 Binary files /dev/null and b/vendor/cache/entitlements-app-1.2.1.gem differ diff --git a/vendor/cache/faraday-2.14.1.gem b/vendor/cache/faraday-2.14.1.gem new file mode 100644 index 0000000..411cca0 Binary files /dev/null and b/vendor/cache/faraday-2.14.1.gem differ diff --git a/vendor/cache/faraday-2.5.2.gem b/vendor/cache/faraday-2.5.2.gem deleted file mode 100644 index a66f174..0000000 Binary files a/vendor/cache/faraday-2.5.2.gem and /dev/null differ diff --git a/vendor/cache/faraday-net_http-3.0.0.gem b/vendor/cache/faraday-net_http-3.0.0.gem deleted file mode 100644 index a990f3a..0000000 Binary files a/vendor/cache/faraday-net_http-3.0.0.gem and /dev/null differ diff --git a/vendor/cache/faraday-net_http-3.4.2.gem b/vendor/cache/faraday-net_http-3.4.2.gem new file mode 100644 index 0000000..3f46590 Binary files /dev/null and b/vendor/cache/faraday-net_http-3.4.2.gem differ diff --git a/vendor/cache/faraday-retry-2.0.0.gem b/vendor/cache/faraday-retry-2.0.0.gem deleted file mode 100644 index 427363a..0000000 Binary files a/vendor/cache/faraday-retry-2.0.0.gem and /dev/null differ diff --git a/vendor/cache/faraday-retry-2.4.0.gem b/vendor/cache/faraday-retry-2.4.0.gem new file mode 100644 index 0000000..c4b57a3 Binary files /dev/null and b/vendor/cache/faraday-retry-2.4.0.gem differ diff --git a/vendor/cache/hashdiff-1.0.1.gem b/vendor/cache/hashdiff-1.0.1.gem deleted file mode 100644 index 4377d55..0000000 Binary files a/vendor/cache/hashdiff-1.0.1.gem and /dev/null differ diff --git a/vendor/cache/hashdiff-1.2.1.gem b/vendor/cache/hashdiff-1.2.1.gem new file mode 100644 index 0000000..250f505 Binary files /dev/null and b/vendor/cache/hashdiff-1.2.1.gem differ diff --git a/vendor/cache/i18n-1.12.0.gem b/vendor/cache/i18n-1.12.0.gem deleted file mode 100644 index c64c068..0000000 Binary files a/vendor/cache/i18n-1.12.0.gem and /dev/null differ diff --git a/vendor/cache/i18n-1.14.7.gem b/vendor/cache/i18n-1.14.7.gem new file mode 100644 index 0000000..9307337 Binary files /dev/null and b/vendor/cache/i18n-1.14.7.gem differ diff --git a/vendor/cache/json-2.19.2.gem b/vendor/cache/json-2.19.2.gem new file mode 100644 index 0000000..aee4844 Binary files /dev/null and b/vendor/cache/json-2.19.2.gem differ diff --git a/vendor/cache/json-2.6.2.gem b/vendor/cache/json-2.6.2.gem deleted file mode 100644 index dfa9204..0000000 Binary files a/vendor/cache/json-2.6.2.gem and /dev/null differ diff --git a/vendor/cache/language_server-protocol-3.17.0.5.gem b/vendor/cache/language_server-protocol-3.17.0.5.gem new file mode 100644 index 0000000..40a28d8 Binary files /dev/null and b/vendor/cache/language_server-protocol-3.17.0.5.gem differ diff --git a/vendor/cache/lint_roller-1.1.0.gem b/vendor/cache/lint_roller-1.1.0.gem new file mode 100644 index 0000000..0f874b6 Binary files /dev/null and b/vendor/cache/lint_roller-1.1.0.gem differ diff --git a/vendor/cache/logger-1.7.0.gem b/vendor/cache/logger-1.7.0.gem new file mode 100644 index 0000000..061f1cc Binary files /dev/null and b/vendor/cache/logger-1.7.0.gem differ diff --git a/vendor/cache/minitest-5.16.3.gem b/vendor/cache/minitest-5.16.3.gem deleted file mode 100644 index ebdb92e..0000000 Binary files a/vendor/cache/minitest-5.16.3.gem and /dev/null differ diff --git a/vendor/cache/minitest-5.25.5.gem b/vendor/cache/minitest-5.25.5.gem new file mode 100644 index 0000000..2ffec49 Binary files /dev/null and b/vendor/cache/minitest-5.25.5.gem differ diff --git a/vendor/cache/mutex_m-0.3.0.gem b/vendor/cache/mutex_m-0.3.0.gem new file mode 100644 index 0000000..85be3e2 Binary files /dev/null and b/vendor/cache/mutex_m-0.3.0.gem differ diff --git a/vendor/cache/net-http-0.9.1.gem b/vendor/cache/net-http-0.9.1.gem new file mode 100644 index 0000000..912cf24 Binary files /dev/null and b/vendor/cache/net-http-0.9.1.gem differ diff --git a/vendor/cache/net-ldap-0.17.1.gem b/vendor/cache/net-ldap-0.17.1.gem deleted file mode 100644 index 76462c6..0000000 Binary files a/vendor/cache/net-ldap-0.17.1.gem and /dev/null differ diff --git a/vendor/cache/net-ldap-0.20.0.gem b/vendor/cache/net-ldap-0.20.0.gem new file mode 100644 index 0000000..ff6f6a2 Binary files /dev/null and b/vendor/cache/net-ldap-0.20.0.gem differ diff --git a/vendor/cache/optimist-3.0.0.gem b/vendor/cache/optimist-3.0.0.gem deleted file mode 100644 index 7ee0e70..0000000 Binary files a/vendor/cache/optimist-3.0.0.gem and /dev/null differ diff --git a/vendor/cache/optimist-3.2.1.gem b/vendor/cache/optimist-3.2.1.gem new file mode 100644 index 0000000..2c7b7b8 Binary files /dev/null and b/vendor/cache/optimist-3.2.1.gem differ diff --git a/vendor/cache/ostruct-0.6.3.gem b/vendor/cache/ostruct-0.6.3.gem new file mode 100644 index 0000000..a1de392 Binary files /dev/null and b/vendor/cache/ostruct-0.6.3.gem differ diff --git a/vendor/cache/parallel-1.22.1.gem b/vendor/cache/parallel-1.22.1.gem deleted file mode 100644 index 5208c79..0000000 Binary files a/vendor/cache/parallel-1.22.1.gem and /dev/null differ diff --git a/vendor/cache/parallel-1.27.0.gem b/vendor/cache/parallel-1.27.0.gem new file mode 100644 index 0000000..1b86f81 Binary files /dev/null and b/vendor/cache/parallel-1.27.0.gem differ diff --git a/vendor/cache/parser-3.1.2.1.gem b/vendor/cache/parser-3.1.2.1.gem deleted file mode 100644 index 7b71167..0000000 Binary files a/vendor/cache/parser-3.1.2.1.gem and /dev/null differ diff --git a/vendor/cache/parser-3.3.10.2.gem b/vendor/cache/parser-3.3.10.2.gem new file mode 100644 index 0000000..92c1d34 Binary files /dev/null and b/vendor/cache/parser-3.3.10.2.gem differ diff --git a/vendor/cache/prism-1.9.0.gem b/vendor/cache/prism-1.9.0.gem new file mode 100644 index 0000000..45fb871 Binary files /dev/null and b/vendor/cache/prism-1.9.0.gem differ diff --git a/vendor/cache/public_suffix-5.0.0.gem b/vendor/cache/public_suffix-5.0.0.gem deleted file mode 100644 index 6b6ed52..0000000 Binary files a/vendor/cache/public_suffix-5.0.0.gem and /dev/null differ diff --git a/vendor/cache/public_suffix-6.0.2.gem b/vendor/cache/public_suffix-6.0.2.gem new file mode 100644 index 0000000..0baf25c Binary files /dev/null and b/vendor/cache/public_suffix-6.0.2.gem differ diff --git a/vendor/cache/racc-1.8.1.gem b/vendor/cache/racc-1.8.1.gem new file mode 100644 index 0000000..ad9e6bb Binary files /dev/null and b/vendor/cache/racc-1.8.1.gem differ diff --git a/vendor/cache/rack-2.2.4.gem b/vendor/cache/rack-2.2.4.gem deleted file mode 100644 index cff677e..0000000 Binary files a/vendor/cache/rack-2.2.4.gem and /dev/null differ diff --git a/vendor/cache/rack-3.1.20.gem b/vendor/cache/rack-3.1.20.gem new file mode 100644 index 0000000..0a5a3ef Binary files /dev/null and b/vendor/cache/rack-3.1.20.gem differ diff --git a/vendor/cache/rake-13.0.6.gem b/vendor/cache/rake-13.0.6.gem deleted file mode 100644 index 19ae802..0000000 Binary files a/vendor/cache/rake-13.0.6.gem and /dev/null differ diff --git a/vendor/cache/rake-13.3.1.gem b/vendor/cache/rake-13.3.1.gem new file mode 100644 index 0000000..75b4aab Binary files /dev/null and b/vendor/cache/rake-13.3.1.gem differ diff --git a/vendor/cache/rbs-3.6.1.gem b/vendor/cache/rbs-3.6.1.gem new file mode 100644 index 0000000..6371bec Binary files /dev/null and b/vendor/cache/rbs-3.6.1.gem differ diff --git a/vendor/cache/regexp_parser-2.11.3.gem b/vendor/cache/regexp_parser-2.11.3.gem new file mode 100644 index 0000000..60eb7aa Binary files /dev/null and b/vendor/cache/regexp_parser-2.11.3.gem differ diff --git a/vendor/cache/regexp_parser-2.5.0.gem b/vendor/cache/regexp_parser-2.5.0.gem deleted file mode 100644 index e2e175e..0000000 Binary files a/vendor/cache/regexp_parser-2.5.0.gem and /dev/null differ diff --git a/vendor/cache/retryable-3.0.5.gem b/vendor/cache/retryable-3.0.5.gem new file mode 100644 index 0000000..9456162 Binary files /dev/null and b/vendor/cache/retryable-3.0.5.gem differ diff --git a/vendor/cache/rexml-3.2.5.gem b/vendor/cache/rexml-3.2.5.gem deleted file mode 100644 index 5680fec..0000000 Binary files a/vendor/cache/rexml-3.2.5.gem and /dev/null differ diff --git a/vendor/cache/rexml-3.4.4.gem b/vendor/cache/rexml-3.4.4.gem new file mode 100644 index 0000000..46cc01a Binary files /dev/null and b/vendor/cache/rexml-3.4.4.gem differ diff --git a/vendor/cache/rspec-3.13.2.gem b/vendor/cache/rspec-3.13.2.gem new file mode 100644 index 0000000..4b00e2a Binary files /dev/null and b/vendor/cache/rspec-3.13.2.gem differ diff --git a/vendor/cache/rspec-3.8.0.gem b/vendor/cache/rspec-3.8.0.gem deleted file mode 100644 index dfd0934..0000000 Binary files a/vendor/cache/rspec-3.8.0.gem and /dev/null differ diff --git a/vendor/cache/rspec-core-3.13.6.gem b/vendor/cache/rspec-core-3.13.6.gem new file mode 100644 index 0000000..98f9a48 Binary files /dev/null and b/vendor/cache/rspec-core-3.13.6.gem differ diff --git a/vendor/cache/rspec-core-3.8.0.gem b/vendor/cache/rspec-core-3.8.0.gem deleted file mode 100644 index b50de32..0000000 Binary files a/vendor/cache/rspec-core-3.8.0.gem and /dev/null differ diff --git a/vendor/cache/rspec-expectations-3.13.5.gem b/vendor/cache/rspec-expectations-3.13.5.gem new file mode 100644 index 0000000..51409fd Binary files /dev/null and b/vendor/cache/rspec-expectations-3.13.5.gem differ diff --git a/vendor/cache/rspec-expectations-3.8.6.gem b/vendor/cache/rspec-expectations-3.8.6.gem deleted file mode 100644 index c34ee0a..0000000 Binary files a/vendor/cache/rspec-expectations-3.8.6.gem and /dev/null differ diff --git a/vendor/cache/rspec-mocks-3.13.7.gem b/vendor/cache/rspec-mocks-3.13.7.gem new file mode 100644 index 0000000..ce97509 Binary files /dev/null and b/vendor/cache/rspec-mocks-3.13.7.gem differ diff --git a/vendor/cache/rspec-mocks-3.8.2.gem b/vendor/cache/rspec-mocks-3.8.2.gem deleted file mode 100644 index 44015e4..0000000 Binary files a/vendor/cache/rspec-mocks-3.8.2.gem and /dev/null differ diff --git a/vendor/cache/rspec-support-3.13.6.gem b/vendor/cache/rspec-support-3.13.6.gem new file mode 100644 index 0000000..a609350 Binary files /dev/null and b/vendor/cache/rspec-support-3.13.6.gem differ diff --git a/vendor/cache/rspec-support-3.8.3.gem b/vendor/cache/rspec-support-3.8.3.gem deleted file mode 100644 index d52de34..0000000 Binary files a/vendor/cache/rspec-support-3.8.3.gem and /dev/null differ diff --git a/vendor/cache/rubocop-1.29.1.gem b/vendor/cache/rubocop-1.29.1.gem deleted file mode 100644 index a76ade0..0000000 Binary files a/vendor/cache/rubocop-1.29.1.gem and /dev/null differ diff --git a/vendor/cache/rubocop-1.86.0.gem b/vendor/cache/rubocop-1.86.0.gem new file mode 100644 index 0000000..0692890 Binary files /dev/null and b/vendor/cache/rubocop-1.86.0.gem differ diff --git a/vendor/cache/rubocop-ast-1.21.0.gem b/vendor/cache/rubocop-ast-1.21.0.gem deleted file mode 100644 index 71f1a4d..0000000 Binary files a/vendor/cache/rubocop-ast-1.21.0.gem and /dev/null differ diff --git a/vendor/cache/rubocop-ast-1.49.1.gem b/vendor/cache/rubocop-ast-1.49.1.gem new file mode 100644 index 0000000..a372961 Binary files /dev/null and b/vendor/cache/rubocop-ast-1.49.1.gem differ diff --git a/vendor/cache/rubocop-github-0.17.0.gem b/vendor/cache/rubocop-github-0.17.0.gem deleted file mode 100644 index 8b54a32..0000000 Binary files a/vendor/cache/rubocop-github-0.17.0.gem and /dev/null differ diff --git a/vendor/cache/rubocop-github-0.23.0.gem b/vendor/cache/rubocop-github-0.23.0.gem new file mode 100644 index 0000000..24dc977 Binary files /dev/null and b/vendor/cache/rubocop-github-0.23.0.gem differ diff --git a/vendor/cache/rubocop-performance-1.13.3.gem b/vendor/cache/rubocop-performance-1.13.3.gem deleted file mode 100644 index 17ca043..0000000 Binary files a/vendor/cache/rubocop-performance-1.13.3.gem and /dev/null differ diff --git a/vendor/cache/rubocop-performance-1.26.1.gem b/vendor/cache/rubocop-performance-1.26.1.gem new file mode 100644 index 0000000..7b5a94c Binary files /dev/null and b/vendor/cache/rubocop-performance-1.26.1.gem differ diff --git a/vendor/cache/rubocop-rails-2.15.2.gem b/vendor/cache/rubocop-rails-2.15.2.gem deleted file mode 100644 index 48018f5..0000000 Binary files a/vendor/cache/rubocop-rails-2.15.2.gem and /dev/null differ diff --git a/vendor/cache/rubocop-rails-2.32.0.gem b/vendor/cache/rubocop-rails-2.32.0.gem new file mode 100644 index 0000000..257d7b7 Binary files /dev/null and b/vendor/cache/rubocop-rails-2.32.0.gem differ diff --git a/vendor/cache/ruby-lsp-0.26.8.gem b/vendor/cache/ruby-lsp-0.26.8.gem new file mode 100644 index 0000000..8e26c22 Binary files /dev/null and b/vendor/cache/ruby-lsp-0.26.8.gem differ diff --git a/vendor/cache/ruby-progressbar-1.11.0.gem b/vendor/cache/ruby-progressbar-1.11.0.gem deleted file mode 100644 index a9d84e5..0000000 Binary files a/vendor/cache/ruby-progressbar-1.11.0.gem and /dev/null differ diff --git a/vendor/cache/ruby-progressbar-1.13.0.gem b/vendor/cache/ruby-progressbar-1.13.0.gem new file mode 100644 index 0000000..c50b94b Binary files /dev/null and b/vendor/cache/ruby-progressbar-1.13.0.gem differ diff --git a/vendor/cache/ruby2_keywords-0.0.5.gem b/vendor/cache/ruby2_keywords-0.0.5.gem deleted file mode 100644 index d311c5d..0000000 Binary files a/vendor/cache/ruby2_keywords-0.0.5.gem and /dev/null differ diff --git a/vendor/cache/rugged-0.27.5.gem b/vendor/cache/rugged-0.27.5.gem deleted file mode 100644 index 79bb2ef..0000000 Binary files a/vendor/cache/rugged-0.27.5.gem and /dev/null differ diff --git a/vendor/cache/rugged-1.9.0.gem b/vendor/cache/rugged-1.9.0.gem new file mode 100644 index 0000000..b9d02f2 Binary files /dev/null and b/vendor/cache/rugged-1.9.0.gem differ diff --git a/vendor/cache/sawyer-0.9.2.gem b/vendor/cache/sawyer-0.9.2.gem deleted file mode 100644 index 5213eb8..0000000 Binary files a/vendor/cache/sawyer-0.9.2.gem and /dev/null differ diff --git a/vendor/cache/sawyer-0.9.3.gem b/vendor/cache/sawyer-0.9.3.gem new file mode 100644 index 0000000..1a6b0db Binary files /dev/null and b/vendor/cache/sawyer-0.9.3.gem differ diff --git a/vendor/cache/securerandom-0.3.2.gem b/vendor/cache/securerandom-0.3.2.gem new file mode 100644 index 0000000..0fdc524 Binary files /dev/null and b/vendor/cache/securerandom-0.3.2.gem differ diff --git a/vendor/cache/simplecov-0.16.1.gem b/vendor/cache/simplecov-0.16.1.gem deleted file mode 100644 index a242eea..0000000 Binary files a/vendor/cache/simplecov-0.16.1.gem and /dev/null differ diff --git a/vendor/cache/simplecov-0.22.0.gem b/vendor/cache/simplecov-0.22.0.gem new file mode 100644 index 0000000..ce8f979 Binary files /dev/null and b/vendor/cache/simplecov-0.22.0.gem differ diff --git a/vendor/cache/simplecov-html-0.10.2.gem b/vendor/cache/simplecov-html-0.10.2.gem deleted file mode 100644 index 363619b..0000000 Binary files a/vendor/cache/simplecov-html-0.10.2.gem and /dev/null differ diff --git a/vendor/cache/simplecov-html-0.12.3.gem b/vendor/cache/simplecov-html-0.12.3.gem new file mode 100644 index 0000000..003d7ca Binary files /dev/null and b/vendor/cache/simplecov-html-0.12.3.gem differ diff --git a/vendor/cache/simplecov_json_formatter-0.1.4.gem b/vendor/cache/simplecov_json_formatter-0.1.4.gem new file mode 100644 index 0000000..75f6f6e Binary files /dev/null and b/vendor/cache/simplecov_json_formatter-0.1.4.gem differ diff --git a/vendor/cache/tzinfo-2.0.5.gem b/vendor/cache/tzinfo-2.0.5.gem deleted file mode 100644 index 1b28f07..0000000 Binary files a/vendor/cache/tzinfo-2.0.5.gem and /dev/null differ diff --git a/vendor/cache/tzinfo-2.0.6.gem b/vendor/cache/tzinfo-2.0.6.gem new file mode 100644 index 0000000..2c16da8 Binary files /dev/null and b/vendor/cache/tzinfo-2.0.6.gem differ diff --git a/vendor/cache/unicode-display_width-2.2.0.gem b/vendor/cache/unicode-display_width-2.2.0.gem deleted file mode 100644 index bece7fa..0000000 Binary files a/vendor/cache/unicode-display_width-2.2.0.gem and /dev/null differ diff --git a/vendor/cache/unicode-display_width-3.2.0.gem b/vendor/cache/unicode-display_width-3.2.0.gem new file mode 100644 index 0000000..37a7d7a Binary files /dev/null and b/vendor/cache/unicode-display_width-3.2.0.gem differ diff --git a/vendor/cache/unicode-emoji-4.2.0.gem b/vendor/cache/unicode-emoji-4.2.0.gem new file mode 100644 index 0000000..3ceb38a Binary files /dev/null and b/vendor/cache/unicode-emoji-4.2.0.gem differ diff --git a/vendor/cache/uri-1.1.1.gem b/vendor/cache/uri-1.1.1.gem new file mode 100644 index 0000000..d1bea0c Binary files /dev/null and b/vendor/cache/uri-1.1.1.gem differ diff --git a/vendor/cache/vcr-4.0.0.gem b/vendor/cache/vcr-4.0.0.gem deleted file mode 100644 index 8b47844..0000000 Binary files a/vendor/cache/vcr-4.0.0.gem and /dev/null differ diff --git a/vendor/cache/vcr-6.4.0.gem b/vendor/cache/vcr-6.4.0.gem new file mode 100644 index 0000000..03d323f Binary files /dev/null and b/vendor/cache/vcr-6.4.0.gem differ diff --git a/vendor/cache/webmock-3.26.2.gem b/vendor/cache/webmock-3.26.2.gem new file mode 100644 index 0000000..f5ddf7a Binary files /dev/null and b/vendor/cache/webmock-3.26.2.gem differ diff --git a/vendor/cache/webmock-3.4.2.gem b/vendor/cache/webmock-3.4.2.gem deleted file mode 100644 index 24a65b8..0000000 Binary files a/vendor/cache/webmock-3.4.2.gem and /dev/null differ