main
rb 101 lines 3.25 KB
Raw
1 # frozen_string_literal: true
2
3 require "rails_helper"
4
5 # The GitHub push webhook that triggers an immediate mirror sync. It must be
6 # authenticated by GitHub's HMAC signature, refuse unsigned/forged requests, and
7 # only fan out to mirrors whose upstream matches the pushed repository.
8 RSpec.describe "GitHub push webhook", type: :request do
9 let(:secret) { "topsecret-webhook-key" }
10
11 # Give the endpoint a configured secret for every example except the one that
12 # asserts the unconfigured behaviour.
13 around do |example|
14 previous = ENV["GITHUB_WEBHOOK_SECRET"]
15 ENV["GITHUB_WEBHOOK_SECRET"] = secret
16 example.run
17 ensure
18 ENV["GITHUB_WEBHOOK_SECRET"] = previous
19 end
20
21 let!(:mirror) do
22 User.create!(smbcloud_id: 900, email: "wh@example.com", username: "whuser")
23 .repositories.create!(
24 name: "widget", disk_path: "/nonexistent/whuser/widget.git", default_branch: "main",
25 mirror: true, upstream_url: "https://github.com/acme/widget.git", mirror_status: "ok"
26 )
27 end
28
29 def sign(body, key = secret)
30 "sha256=" + OpenSSL::HMAC.hexdigest("sha256", key, body)
31 end
32
33 def deliver(event, payload, signature: nil)
34 body = payload.is_a?(String) ? payload : JSON.generate(payload)
35 post "/webhooks/github", params: body, headers: {
36 "CONTENT_TYPE" => "application/json",
37 "X-GitHub-Event" => event,
38 "X-Hub-Signature-256" => signature || sign(body)
39 }
40 end
41
42 it "syncs a mirror whose upstream matches the pushed repo" do
43 expect(MirrorSyncJob).to receive(:perform_later).with(mirror.id)
44
45 deliver("push", { repository: { clone_url: "https://github.com/acme/widget.git" } })
46
47 expect(response).to have_http_status(:ok)
48 end
49
50 it "does nothing for a push to an unrelated repo" do
51 expect(MirrorSyncJob).not_to receive(:perform_later)
52
53 deliver("push", { repository: { clone_url: "https://github.com/someone/else.git" } })
54
55 expect(response).to have_http_status(:ok)
56 end
57
58 it "acknowledges a ping without syncing" do
59 expect(MirrorSyncJob).not_to receive(:perform_later)
60
61 deliver("ping", { zen: "Keep it logically awesome." })
62
63 expect(response).to have_http_status(:ok)
64 end
65
66 it "rejects a request with a bad signature" do
67 expect(MirrorSyncJob).not_to receive(:perform_later)
68
69 deliver("push", { repository: { clone_url: "https://github.com/acme/widget.git" } },
70 signature: sign("tampered"))
71
72 expect(response).to have_http_status(:unauthorized)
73 end
74
75 it "rejects a request with no signature" do
76 expect(MirrorSyncJob).not_to receive(:perform_later)
77
78 deliver("push", { repository: { clone_url: "https://github.com/acme/widget.git" } },
79 signature: "")
80
81 expect(response).to have_http_status(:unauthorized)
82 end
83
84 it "refuses to serve when no webhook secret is configured" do
85 ENV["GITHUB_WEBHOOK_SECRET"] = nil
86 expect(MirrorSyncJob).not_to receive(:perform_later)
87
88 deliver("push", { repository: { clone_url: "https://github.com/acme/widget.git" } },
89 signature: "sha256=whatever")
90
91 expect(response).to have_http_status(:service_unavailable)
92 end
93
94 it "ignores non-push events" do
95 expect(MirrorSyncJob).not_to receive(:perform_later)
96
97 deliver("issues", { action: "opened" })
98
99 expect(response).to have_http_status(:ok)
100 end
101 end