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