| 1 | # frozen_string_literal: true |
| 2 | |
| 3 | require "rails_helper" |
| 4 | |
| 5 | # The GitHub OAuth connect flow for imports: the callback must verify state, |
| 6 | # exchange the code, store the token encrypted, and never leak it to the client. |
| 7 | RSpec.describe "GitHub import connection", type: :request do |
| 8 | let(:user) { User.create!(smbcloud_id: 99, email: "conn@example.com", username: "connuser") } |
| 9 | |
| 10 | before do |
| 11 | # Sign the user in (session-based auth) without going through smbCloud. |
| 12 | allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(user) |
| 13 | allow(GithubOauthService).to receive(:configured?).and_return(true) |
| 14 | allow(GithubOauthService).to receive(:generate_state).and_return("state-xyz") |
| 15 | allow(GithubOauthService).to receive(:authorize_url).and_return("https://github.com/login/oauth/authorize?x=1") |
| 16 | end |
| 17 | |
| 18 | # Establishes the CSRF state in the session, the way the real flow does. |
| 19 | def start_connect |
| 20 | get github_import_connect_path |
| 21 | expect(response).to redirect_to("https://github.com/login/oauth/authorize?x=1") |
| 22 | end |
| 23 | |
| 24 | it "exchanges the code and stores the token encrypted on callback" do |
| 25 | start_connect |
| 26 | allow(GithubOauthService).to receive(:exchange_code) |
| 27 | .and_return(access_token: "gho_thetoken", scope: "repo", token_type: "bearer") |
| 28 | fake_client = instance_double(GithubApiClient, login: "octocat", list_repositories: []) |
| 29 | allow(GithubApiClient).to receive(:new).with("gho_thetoken").and_return(fake_client) |
| 30 | |
| 31 | get github_import_callback_path, params: { code: "abc", state: "state-xyz" } |
| 32 | |
| 33 | expect(response).to redirect_to(new_import_path) |
| 34 | conn = user.reload.github_connection |
| 35 | expect(conn).to be_present |
| 36 | expect(conn.access_token).to eq("gho_thetoken") |
| 37 | expect(conn.github_login).to eq("octocat") |
| 38 | |
| 39 | # Ciphertext at rest, not the plaintext token. |
| 40 | raw = ActiveRecord::Base.connection.select_value( |
| 41 | "SELECT access_token FROM github_connections WHERE id = #{conn.id}" |
| 42 | ) |
| 43 | expect(raw).not_to include("gho_thetoken") |
| 44 | |
| 45 | # The token is never echoed back to the client. |
| 46 | expect(response.body).not_to include("gho_thetoken") |
| 47 | follow_redirect! |
| 48 | expect(response.body).not_to include("gho_thetoken") |
| 49 | end |
| 50 | |
| 51 | it "rejects a callback whose state doesn't match (CSRF)" do |
| 52 | start_connect |
| 53 | expect(GithubOauthService).not_to receive(:exchange_code) |
| 54 | |
| 55 | get github_import_callback_path, params: { code: "abc", state: "wrong-state" } |
| 56 | |
| 57 | expect(response).to redirect_to(new_import_path) |
| 58 | expect(user.reload.github_connection).to be_nil |
| 59 | end |
| 60 | |
| 61 | it "handles a user cancelling the GitHub authorization" do |
| 62 | get github_import_callback_path, params: { error: "access_denied" } |
| 63 | expect(response).to redirect_to(new_import_path) |
| 64 | expect(user.reload.github_connection).to be_nil |
| 65 | end |
| 66 | |
| 67 | it "disconnects and removes the stored token" do |
| 68 | user.create_github_connection!(access_token: "gho_x", scope: "repo") |
| 69 | delete github_import_disconnect_path |
| 70 | expect(user.reload.github_connection).to be_nil |
| 71 | end |
| 72 | end |