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