main
rb 137 lines 5.75 KB
Raw
1 # frozen_string_literal: true
2
3 require "rails_helper"
4
5 RSpec.describe GithubAppService do
6 let(:rsa_key) { OpenSSL::PKey::RSA.new(2048) }
7
8 before do
9 allow(described_class).to receive_messages(
10 app_id: "12345",
11 webhook_secret: "hook-secret",
12 private_key_pem: rsa_key.to_pem
13 )
14 end
15
16 let(:installation) do
17 GithubAppInstallation.create!(installation_id: 555, account_login: "octocat")
18 end
19
20 def token_response(token: "ghs_fresh", expires_in: 1.hour)
21 { status: 201,
22 body: { token: token, expires_at: expires_in.from_now.utc.iso8601 }.to_json,
23 headers: { "Content-Type" => "application/json" } }
24 end
25
26 describe ".app_jwt" do
27 it "mints an RS256 JWT with the app id and a <10 minute lifetime" do
28 payload, header = JWT.decode(described_class.send(:app_jwt), rsa_key.public_key, true,
29 algorithm: "RS256")
30
31 expect(header["alg"]).to eq("RS256")
32 expect(payload["iss"]).to eq("12345")
33 expect(payload["iat"]).to be < Time.current.to_i # back-dated for clock skew
34 expect(payload["exp"] - Time.current.to_i).to be_between(1, 600)
35 end
36 end
37
38 describe ".installation_token" do
39 it "mints and caches a token on the installation row" do
40 mint = stub_request(:post, "https://api.github.com/app/installations/555/access_tokens")
41 .to_return(token_response)
42
43 expect(described_class.installation_token(installation)).to eq("ghs_fresh")
44 expect(installation.reload.access_token).to eq("ghs_fresh")
45 expect(installation.access_token_expires_at).to be_within(2.minutes).of(1.hour.from_now)
46
47 expect(described_class.installation_token(installation)).to eq("ghs_fresh")
48 expect(mint).to have_been_requested.once # second call served from the row
49 end
50
51 it "re-mints when the cached token is near expiry" do
52 installation.update!(access_token: "ghs_stale", access_token_expires_at: 2.minutes.from_now)
53 stub_request(:post, "https://api.github.com/app/installations/555/access_tokens")
54 .to_return(token_response(token: "ghs_new"))
55
56 expect(described_class.installation_token(installation)).to eq("ghs_new")
57 end
58 end
59
60 describe ".verify_webhook_signature?" do
61 let(:payload) { '{"zen":"Design for failure."}' }
62 let(:signature) { "sha256=#{OpenSSL::HMAC.hexdigest("SHA256", "hook-secret", payload)}" }
63
64 it "accepts the correct HMAC" do
65 expect(described_class.verify_webhook_signature?(payload, signature)).to be true
66 end
67
68 it "rejects a tampered payload, a missing header, and a missing secret" do
69 expect(described_class.verify_webhook_signature?(payload + " ", signature)).to be false
70 expect(described_class.verify_webhook_signature?(payload, nil)).to be false
71
72 allow(described_class).to receive(:webhook_secret).and_return(nil)
73 expect(described_class.verify_webhook_signature?(payload, signature)).to be false
74 end
75 end
76
77 describe "REST calls" do
78 before do
79 installation.update!(access_token: "ghs_live", access_token_expires_at: 1.hour.from_now)
80 end
81
82 it "fetches a pull request with App headers" do
83 stub_request(:get, "https://api.github.com/repos/octocat/hello/pulls/7")
84 .with(headers: { "Authorization" => "Bearer ghs_live",
85 "X-GitHub-Api-Version" => "2022-11-28" })
86 .to_return(status: 200, body: { "number" => 7 }.to_json)
87
88 expect(described_class.pull_request(installation, "octocat/hello", 7)).to eq("number" => 7)
89 end
90
91 it "follows Link pagination when listing files" do
92 page_two = "https://api.github.com/repos/octocat/hello/pulls/7/files?per_page=100&page=2"
93 stub_request(:get, "https://api.github.com/repos/octocat/hello/pulls/7/files?per_page=100")
94 .to_return(status: 200, body: [ { "filename" => "a.rb" } ].to_json,
95 headers: { "Link" => "<#{page_two}>; rel=\"next\"" })
96 stub_request(:get, page_two)
97 .to_return(status: 200, body: [ { "filename" => "b.rb" } ].to_json)
98
99 files = described_class.pull_request_files(installation, "octocat/hello", 7)
100 expect(files.map { |f| f["filename"] }).to eq(%w[a.rb b.rb])
101 end
102
103 it "creates a review with the summary body and line comments" do
104 create = stub_request(:post, "https://api.github.com/repos/octocat/hello/pulls/7/reviews")
105 .with(body: hash_including(
106 "commit_id" => "abc123", "event" => "COMMENT", "body" => "Walkthrough",
107 "comments" => [ { "path" => "a.rb", "line" => 2, "side" => "RIGHT", "body" => "hm" } ]
108 ))
109 .to_return(status: 200, body: "{}")
110
111 described_class.create_review(installation, "octocat/hello", 7,
112 commit_id: "abc123", body: "Walkthrough",
113 comments: [ { "path" => "a.rb", "line" => 2, "side" => "RIGHT", "body" => "hm" } ])
114 expect(create).to have_been_requested
115 end
116
117 it "raises ApiError carrying status and body on 422" do
118 stub_request(:post, "https://api.github.com/repos/octocat/hello/pulls/7/reviews")
119 .to_return(status: 422, body: '{"message":"Unprocessable"}')
120
121 expect do
122 described_class.create_review(installation, "octocat/hello", 7,
123 commit_id: "abc123", body: "x")
124 end.to raise_error(described_class::ApiError) { |error|
125 expect(error.status).to eq(422)
126 expect(error.body).to include("Unprocessable")
127 }
128 end
129
130 it "wraps connection failures as a 502 ApiError" do
131 stub_request(:get, "https://api.github.com/repos/octocat/hello/pulls/7").to_timeout
132
133 expect { described_class.pull_request(installation, "octocat/hello", 7) }
134 .to raise_error(described_class::ApiError) { |error| expect(error.status).to eq(502) }
135 end
136 end
137 end