| 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 |