Add request specs for mirror actions and the GitHub push webhook

The detach/sync controller and the webhook endpoint shipped without coverage. These specs check owner-only access on detach and sync, that a non-mirror repo is refused, and that the webhook verifies GitHub's HMAC signature (and its configured/unconfigured states) before queuing a sync. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Seto Elkahfi committed Jul 5, 2026 at 21:52 UTC b832ad25b1f957e3023dd7324cadfbc4791f9954
2 files changed +198
spec/requests/github_webhooks_spec.rb
+101
new file mode 100644 index 0000000..cc130bd --- /dev/null +++ b/spec/requests/github_webhooks_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require "rails_helper" + +# The GitHub push webhook that triggers an immediate mirror sync. It must be +# authenticated by GitHub's HMAC signature, refuse unsigned/forged requests, and +# only fan out to mirrors whose upstream matches the pushed repository. +RSpec.describe "GitHub push webhook", type: :request do + let(:secret) { "topsecret-webhook-key" } + + # Give the endpoint a configured secret for every example except the one that + # asserts the unconfigured behaviour. + around do |example| + previous = ENV["GITHUB_WEBHOOK_SECRET"] + ENV["GITHUB_WEBHOOK_SECRET"] = secret + example.run + ensure + ENV["GITHUB_WEBHOOK_SECRET"] = previous + end + + let!(:mirror) do + User.create!(smbcloud_id: 900, email: "wh@example.com", username: "whuser") + .repositories.create!( + name: "widget", disk_path: "/nonexistent/whuser/widget.git", default_branch: "main", + mirror: true, upstream_url: "https://github.com/acme/widget.git", mirror_status: "ok" + ) + end + + def sign(body, key = secret) + "sha256=" + OpenSSL::HMAC.hexdigest("sha256", key, body) + end + + def deliver(event, payload, signature: nil) + body = payload.is_a?(String) ? payload : JSON.generate(payload) + post "/webhooks/github", params: body, headers: { + "CONTENT_TYPE" => "application/json", + "X-GitHub-Event" => event, + "X-Hub-Signature-256" => signature || sign(body) + } + end + + it "syncs a mirror whose upstream matches the pushed repo" do + expect(MirrorSyncJob).to receive(:perform_later).with(mirror.id) + + deliver("push", { repository: { clone_url: "https://github.com/acme/widget.git" } }) + + expect(response).to have_http_status(:ok) + end + + it "does nothing for a push to an unrelated repo" do + expect(MirrorSyncJob).not_to receive(:perform_later) + + deliver("push", { repository: { clone_url: "https://github.com/someone/else.git" } }) + + expect(response).to have_http_status(:ok) + end + + it "acknowledges a ping without syncing" do + expect(MirrorSyncJob).not_to receive(:perform_later) + + deliver("ping", { zen: "Keep it logically awesome." }) + + expect(response).to have_http_status(:ok) + end + + it "rejects a request with a bad signature" do + expect(MirrorSyncJob).not_to receive(:perform_later) + + deliver("push", { repository: { clone_url: "https://github.com/acme/widget.git" } }, + signature: sign("tampered")) + + expect(response).to have_http_status(:unauthorized) + end + + it "rejects a request with no signature" do + expect(MirrorSyncJob).not_to receive(:perform_later) + + deliver("push", { repository: { clone_url: "https://github.com/acme/widget.git" } }, + signature: "") + + expect(response).to have_http_status(:unauthorized) + end + + it "refuses to serve when no webhook secret is configured" do + ENV["GITHUB_WEBHOOK_SECRET"] = nil + expect(MirrorSyncJob).not_to receive(:perform_later) + + deliver("push", { repository: { clone_url: "https://github.com/acme/widget.git" } }, + signature: "sha256=whatever") + + expect(response).to have_http_status(:service_unavailable) + end + + it "ignores non-push events" do + expect(MirrorSyncJob).not_to receive(:perform_later) + + deliver("issues", { action: "opened" }) + + expect(response).to have_http_status(:ok) + end +end
spec/requests/mirrors_spec.rb
+97
new file mode 100644 index 0000000..7284efa --- /dev/null +++ b/spec/requests/mirrors_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Owner-only actions on a mirror repository: detach (convert it into a normal +# writable repo and stop syncing) and a manual sync trigger. Everyone else — +# other users and anonymous visitors — must not be able to reach either. +RSpec.describe "Mirror actions", type: :request do + let(:owner) { User.create!(smbcloud_id: 800, email: "owner@example.com", username: "owner") } + let(:other) { User.create!(smbcloud_id: 801, email: "other@example.com", username: "other") } + + let!(:mirror) do + owner.repositories.create!( + name: "mirrored", disk_path: "/nonexistent/owner/mirrored.git", default_branch: "main", + mirror: true, upstream_url: "https://github.com/acme/mirrored.git", + upstream_token: "gho_secrettoken", mirror_status: "ok" + ) + end + + # Session-based sign-in without going through smbCloud (mirrors the pattern in + # github_connections_spec). `signed_in?` derives from `current_user`, so + # stubbing the reader is enough. + def sign_in(user) + allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(user) + end + + describe "POST /:username/:repository/mirror/detach" do + it "converts the mirror into a normal writable repo for its owner" do + sign_in(owner) + post repository_mirror_detach_path(owner.username, mirror.name) + + expect(response).to redirect_to(repository_path(owner.username, mirror.name)) + mirror.reload + expect(mirror.mirror?).to be(false) + expect(mirror.upstream_token).to be_nil # credential dropped + expect(mirror.writable_by?(owner)).to be(true) # now pushable + end + + it "is a 404 for a signed-in non-owner and leaves the mirror intact" do + sign_in(other) + post repository_mirror_detach_path(owner.username, mirror.name) + + expect(response).to have_http_status(:not_found) + expect(mirror.reload.mirror?).to be(true) + end + + it "redirects anonymous visitors to sign in" do + post repository_mirror_detach_path(owner.username, mirror.name) + + expect(response).to redirect_to(signin_path) + expect(mirror.reload.mirror?).to be(true) + end + + it "reports plainly when the repo isn't a mirror" do + normal = owner.repositories.create!(name: "plain", disk_path: "/nonexistent/owner/plain.git", + default_branch: "main") + sign_in(owner) + post repository_mirror_detach_path(owner.username, normal.name) + + expect(response).to redirect_to(repository_path(owner.username, normal.name)) + expect(flash[:alert]).to match(/isn't a mirror/i) + end + end + + describe "POST /:username/:repository/mirror/sync" do + it "queues a MirrorSyncJob for the owner" do + sign_in(owner) + expect(MirrorSyncJob).to receive(:perform_later).with(mirror.id) + + post repository_mirror_sync_path(owner.username, mirror.name) + + expect(response).to redirect_to(repository_path(owner.username, mirror.name)) + expect(flash[:notice]).to match(/queued/i) + end + + it "is a 404 for a non-owner and enqueues nothing" do + sign_in(other) + expect(MirrorSyncJob).not_to receive(:perform_later) + + post repository_mirror_sync_path(owner.username, mirror.name) + + expect(response).to have_http_status(:not_found) + end + + it "refuses to sync a repo that isn't a mirror" do + normal = owner.repositories.create!(name: "plain2", disk_path: "/nonexistent/owner/plain2.git", + default_branch: "main") + sign_in(owner) + expect(MirrorSyncJob).not_to receive(:perform_later) + + post repository_mirror_sync_path(owner.username, normal.name) + + expect(response).to redirect_to(repository_path(owner.username, normal.name)) + expect(flash[:alert]).to match(/isn't a mirror/i) + end + end +end