main
rb 205 lines 7.83 KB
Raw
1 # frozen_string_literal: true
2
3 require "rails_helper"
4
5 RSpec.describe "GitHub webhooks", type: :request do
6 before do
7 ActiveJob::Base.queue_adapter = :test
8 allow(GithubAppService).to receive_messages(configured?: true, webhook_secret: "hook-secret")
9 end
10
11 def deliver(event, payload, secret: "hook-secret")
12 body = payload.to_json
13 post "/github/webhooks", params: body, headers: {
14 "CONTENT_TYPE" => "application/json",
15 "X-GitHub-Event" => event,
16 "X-Hub-Signature-256" => "sha256=#{OpenSSL::HMAC.hexdigest("SHA256", secret, body)}"
17 }
18 end
19
20 def installation_payload(action, id: 555, selection: "selected")
21 { action: action,
22 installation: { id: id, account: { login: "octocat", type: "User", id: 9 },
23 repository_selection: selection } }
24 end
25
26 def pull_request_payload(action, draft: false, number: 7, sha: "headsha1")
27 installation_payload(action).merge(
28 repository: { full_name: "octocat/hello" },
29 pull_request: { number: number, draft: draft, head: { sha: sha } }
30 )
31 end
32
33 describe "authentication" do
34 it "rejects a tampered body" do
35 body = installation_payload("created").to_json
36 post "/github/webhooks", params: body + " ", headers: {
37 "CONTENT_TYPE" => "application/json",
38 "X-GitHub-Event" => "installation",
39 "X-Hub-Signature-256" => "sha256=#{OpenSSL::HMAC.hexdigest("SHA256", "hook-secret", body)}"
40 }
41
42 expect(response).to have_http_status(:unauthorized)
43 expect(GithubAppInstallation.count).to eq(0)
44 end
45
46 it "rejects a signature under the wrong secret" do
47 deliver("installation", installation_payload("created"), secret: "wrong")
48 expect(response).to have_http_status(:unauthorized)
49 end
50
51 it "returns 503 when the app is not configured" do
52 allow(GithubAppService).to receive(:configured?).and_return(false)
53 deliver("installation", installation_payload("created"))
54 expect(response).to have_http_status(:service_unavailable)
55 end
56 end
57
58 describe "installation events" do
59 it "upserts the installation on created" do
60 deliver("installation", installation_payload("created"))
61
62 expect(response).to have_http_status(:ok)
63 installation = GithubAppInstallation.find_by!(installation_id: 555)
64 expect(installation).to have_attributes(
65 account_login: "octocat", account_type: "User", account_id: 9,
66 repository_selection: "selected", deleted_at: nil
67 )
68 end
69
70 it "soft-deletes and clears the cached token on deleted" do
71 installation = GithubAppInstallation.create!(
72 installation_id: 555, account_login: "octocat",
73 access_token: "ghs_x", access_token_expires_at: 1.hour.from_now
74 )
75
76 deliver("installation", installation_payload("deleted"))
77
78 expect(installation.reload.deleted_at).to be_present
79 expect(installation.access_token).to be_nil
80 end
81
82 it "suspends and unsuspends" do
83 deliver("installation", installation_payload("suspend"))
84 installation = GithubAppInstallation.find_by!(installation_id: 555)
85 expect(installation.suspended_at).to be_present
86
87 deliver("installation", installation_payload("unsuspend"))
88 expect(installation.reload.suspended_at).to be_nil
89 end
90
91 it "refreshes repository_selection on installation_repositories" do
92 GithubAppInstallation.create!(installation_id: 555, account_login: "octocat",
93 repository_selection: "selected")
94 deliver("installation_repositories", installation_payload("added", selection: "all"))
95
96 expect(GithubAppInstallation.find_by!(installation_id: 555).repository_selection).to eq("all")
97 end
98 end
99
100 describe "pull_request events" do
101 it "enqueues a review for an opened PR (creating the installation if unseen)" do
102 expect do
103 deliver("pull_request", pull_request_payload("opened"))
104 end.to have_enqueued_job(GithubPrReviewJob).with(
105 installation_id: GithubAppInstallation.last&.id || kind_of(Integer),
106 repo_full_name: "octocat/hello", pr_number: 7, head_sha: "headsha1"
107 ).on_queue("default")
108
109 expect(response).to have_http_status(:ok)
110 expect(GithubAppInstallation.find_by(installation_id: 555)).to be_present
111 end
112
113 it "enqueues for synchronize, reopened, and ready_for_review" do
114 %w[synchronize reopened ready_for_review].each do |action|
115 expect { deliver("pull_request", pull_request_payload(action)) }
116 .to have_enqueued_job(GithubPrReviewJob)
117 end
118 end
119
120 it "ignores other PR actions" do
121 expect { deliver("pull_request", pull_request_payload("labeled")) }
122 .not_to have_enqueued_job
123 expect(response).to have_http_status(:ok)
124 end
125
126 it "skips draft PRs" do
127 expect { deliver("pull_request", pull_request_payload("opened", draft: true)) }
128 .not_to have_enqueued_job
129 end
130
131 it "skips when the kill switch is off" do
132 allow(GithubReviewConfig).to receive(:enabled?).and_return(false)
133 expect { deliver("pull_request", pull_request_payload("opened")) }
134 .not_to have_enqueued_job
135 end
136
137 it "skips suspended installations" do
138 GithubAppInstallation.create!(installation_id: 555, account_login: "octocat",
139 suspended_at: Time.current)
140 expect { deliver("pull_request", pull_request_payload("opened")) }
141 .not_to have_enqueued_job
142 end
143 end
144
145 describe "end to end: signed webhook through job to posted review" do
146 include ActiveJob::TestHelper
147
148 it "reviews the PR and completes the review row" do
149 allow(GithubAppService).to receive_messages(
150 pull_request: {
151 "number" => 7, "state" => "open", "draft" => false, "title" => "Fix login",
152 "body" => "", "base" => { "ref" => "main" },
153 "head" => { "ref" => "fix", "sha" => "headsha1" }
154 },
155 pull_request_files: [
156 { "filename" => "app/a.rb", "status" => "modified", "additions" => 1,
157 "deletions" => 0, "patch" => "@@ -1,2 +1,3 @@\n one\n+two\n three" }
158 ],
159 create_review: {}
160 )
161 allow(GithubReviewConfig).to receive_messages(enabled?: true, model: "onde-large")
162 allow(OndeCloudService).to receive(:create).and_return(
163 "choices" => [ { "message" => { "content" => {
164 "summary" => "Adds a line.",
165 "findings" => [ { "path" => "app/a.rb", "line" => 2, "severity" => "nit", "comment" => "Name it." } ]
166 }.to_json } } ]
167 )
168
169 perform_enqueued_jobs do
170 deliver("pull_request", pull_request_payload("opened"))
171 end
172
173 expect(GithubAppService).to have_received(:create_review) do |_inst, repo, number, commit_id:, comments:, **|
174 expect([ repo, number, commit_id ]).to eq([ "octocat/hello", 7, "headsha1" ])
175 expect(comments.first).to include("path" => "app/a.rb", "line" => 2, "side" => "RIGHT")
176 end
177 expect(GithubPrReview.last).to have_attributes(
178 status: "completed", repo_full_name: "octocat/hello", head_sha: "headsha1", comment_count: 1
179 )
180 end
181 end
182
183 describe "resilience" do
184 it "acks unknown events" do
185 deliver("star", { action: "created" })
186 expect(response).to have_http_status(:ok)
187 end
188
189 it "returns 400 for unparseable JSON" do
190 body = "{not json"
191 post "/github/webhooks", params: body, headers: {
192 "CONTENT_TYPE" => "application/json",
193 "X-GitHub-Event" => "installation",
194 "X-Hub-Signature-256" => "sha256=#{OpenSSL::HMAC.hexdigest("SHA256", "hook-secret", body)}"
195 }
196 expect(response).to have_http_status(:bad_request)
197 end
198
199 it "still acks when handling raises, so GitHub doesn't mark the endpoint dead" do
200 allow(GithubAppInstallation).to receive(:find_or_initialize_by).and_raise("boom")
201 deliver("installation", installation_payload("created"))
202 expect(response).to have_http_status(:ok)
203 end
204 end
205 end