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 new
+101
@@ -0,0 +1,101 @@
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
spec/requests/mirrors_spec.rb new
+97
@@ -0,0 +1,97 @@
1 +# frozen_string_literal: true
2 +
3 +require "rails_helper"
4 +
5 +# Owner-only actions on a mirror repository: detach (convert it into a normal
6 +# writable repo and stop syncing) and a manual sync trigger. Everyone else —
7 +# other users and anonymous visitors — must not be able to reach either.
8 +RSpec.describe "Mirror actions", type: :request do
9 + let(:owner) { User.create!(smbcloud_id: 800, email: "owner@example.com", username: "owner") }
10 + let(:other) { User.create!(smbcloud_id: 801, email: "other@example.com", username: "other") }
11 +
12 + let!(:mirror) do
13 + owner.repositories.create!(
14 + name: "mirrored", disk_path: "/nonexistent/owner/mirrored.git", default_branch: "main",
15 + mirror: true, upstream_url: "https://github.com/acme/mirrored.git",
16 + upstream_token: "gho_secrettoken", mirror_status: "ok"
17 + )
18 + end
19 +
20 + # Session-based sign-in without going through smbCloud (mirrors the pattern in
21 + # github_connections_spec). `signed_in?` derives from `current_user`, so
22 + # stubbing the reader is enough.
23 + def sign_in(user)
24 + allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(user)
25 + end
26 +
27 + describe "POST /:username/:repository/mirror/detach" do
28 + it "converts the mirror into a normal writable repo for its owner" do
29 + sign_in(owner)
30 + post repository_mirror_detach_path(owner.username, mirror.name)
31 +
32 + expect(response).to redirect_to(repository_path(owner.username, mirror.name))
33 + mirror.reload
34 + expect(mirror.mirror?).to be(false)
35 + expect(mirror.upstream_token).to be_nil # credential dropped
36 + expect(mirror.writable_by?(owner)).to be(true) # now pushable
37 + end
38 +
39 + it "is a 404 for a signed-in non-owner and leaves the mirror intact" do
40 + sign_in(other)
41 + post repository_mirror_detach_path(owner.username, mirror.name)
42 +
43 + expect(response).to have_http_status(:not_found)
44 + expect(mirror.reload.mirror?).to be(true)
45 + end
46 +
47 + it "redirects anonymous visitors to sign in" do
48 + post repository_mirror_detach_path(owner.username, mirror.name)
49 +
50 + expect(response).to redirect_to(signin_path)
51 + expect(mirror.reload.mirror?).to be(true)
52 + end
53 +
54 + it "reports plainly when the repo isn't a mirror" do
55 + normal = owner.repositories.create!(name: "plain", disk_path: "/nonexistent/owner/plain.git",
56 + default_branch: "main")
57 + sign_in(owner)
58 + post repository_mirror_detach_path(owner.username, normal.name)
59 +
60 + expect(response).to redirect_to(repository_path(owner.username, normal.name))
61 + expect(flash[:alert]).to match(/isn't a mirror/i)
62 + end
63 + end
64 +
65 + describe "POST /:username/:repository/mirror/sync" do
66 + it "queues a MirrorSyncJob for the owner" do
67 + sign_in(owner)
68 + expect(MirrorSyncJob).to receive(:perform_later).with(mirror.id)
69 +
70 + post repository_mirror_sync_path(owner.username, mirror.name)
71 +
72 + expect(response).to redirect_to(repository_path(owner.username, mirror.name))
73 + expect(flash[:notice]).to match(/queued/i)
74 + end
75 +
76 + it "is a 404 for a non-owner and enqueues nothing" do
77 + sign_in(other)
78 + expect(MirrorSyncJob).not_to receive(:perform_later)
79 +
80 + post repository_mirror_sync_path(owner.username, mirror.name)
81 +
82 + expect(response).to have_http_status(:not_found)
83 + end
84 +
85 + it "refuses to sync a repo that isn't a mirror" do
86 + normal = owner.repositories.create!(name: "plain2", disk_path: "/nonexistent/owner/plain2.git",
87 + default_branch: "main")
88 + sign_in(owner)
89 + expect(MirrorSyncJob).not_to receive(:perform_later)
90 +
91 + post repository_mirror_sync_path(owner.username, normal.name)
92 +
93 + expect(response).to redirect_to(repository_path(owner.username, normal.name))
94 + expect(flash[:alert]).to match(/isn't a mirror/i)
95 + end
96 + end
97 +end