Adds POST /webhooks/brain-sync: verifies Gitea's HMAC-SHA256 signature, checks the push is to mathias/brain main, then creates a one-off Job from the existing brain-sync CronJob's template (same script the 15-min poll already runs, just triggered on-demand). Off by default -- opt in via GITEA_WEBHOOK_SECRET, since it needs Job-create RBAC in the "brain" namespace a fresh deploy won't have. 10 new tests (internal/webhook), including a fake-clientset reactor to simulate server-side GenerateName expansion, which the plain fake tracker doesn't do on its own. Needs (follow-up, infra repo): RBAC granting ingestion's ServiceAccount get on cronjobs/brain-sync + create on jobs in the brain namespace, the GITEA_WEBHOOK_SECRET env, and the actual Gitea webhook registration.
223 lines
7.6 KiB
Go
223 lines
7.6 KiB
Go
package webhook
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
batchv1 "k8s.io/api/batch/v1"
|
|
corev1 "k8s.io/api/core/v1"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/client-go/kubernetes/fake"
|
|
k8stesting "k8s.io/client-go/testing"
|
|
)
|
|
|
|
// newFakeClientset simulates the real API server's GenerateName expansion,
|
|
// which the plain fake clientset tracker does not do on its own -- without
|
|
// this, every created Job keeps an empty Name (a fake-clientset limitation,
|
|
// not real API server behavior).
|
|
func newFakeClientset(objects ...runtime.Object) *fake.Clientset {
|
|
cs := fake.NewSimpleClientset(objects...)
|
|
cs.PrependReactor("create", "jobs", func(action k8stesting.Action) (bool, runtime.Object, error) {
|
|
createAction := action.(k8stesting.CreateAction)
|
|
job, ok := createAction.GetObject().(*batchv1.Job)
|
|
if ok && job.Name == "" && job.GenerateName != "" {
|
|
job.Name = job.GenerateName + "test0001"
|
|
}
|
|
return false, nil, nil // not "handled" -- let the default reactor store it
|
|
})
|
|
return cs
|
|
}
|
|
|
|
func sign(t *testing.T, payload []byte, secret string) string {
|
|
t.Helper()
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
mac.Write(payload)
|
|
return hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
func testLogger() *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(os.Stderr, nil))
|
|
}
|
|
|
|
func fakeCronJob(namespace, name string) *batchv1.CronJob {
|
|
return &batchv1.CronJob{
|
|
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
|
|
Spec: batchv1.CronJobSpec{
|
|
JobTemplate: batchv1.JobTemplateSpec{
|
|
Spec: batchv1.JobSpec{
|
|
Template: corev1.PodTemplateSpec{
|
|
Spec: corev1.PodSpec{
|
|
Containers: []corev1.Container{
|
|
{Name: "sync", Image: "alpine/git:v2.47.2"},
|
|
},
|
|
RestartPolicy: corev1.RestartPolicyNever,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------------- #
|
|
// VerifySignature
|
|
// --------------------------------------------------------------------------- #
|
|
func TestVerifySignature_AcceptsCorrectHMAC(t *testing.T) {
|
|
payload := []byte(`{"ref":"refs/heads/main"}`)
|
|
secret := "s3cret"
|
|
sig := sign(t, payload, secret)
|
|
assert.True(t, VerifySignature(payload, sig, secret))
|
|
}
|
|
|
|
func TestVerifySignature_RejectsWrongSecret(t *testing.T) {
|
|
payload := []byte(`{"ref":"refs/heads/main"}`)
|
|
sig := sign(t, payload, "right-secret")
|
|
assert.False(t, VerifySignature(payload, sig, "wrong-secret"))
|
|
}
|
|
|
|
func TestVerifySignature_RejectsTamperedPayload(t *testing.T) {
|
|
secret := "s3cret"
|
|
sig := sign(t, []byte(`{"ref":"refs/heads/main"}`), secret)
|
|
assert.False(t, VerifySignature([]byte(`{"ref":"refs/heads/evil"}`), sig, secret))
|
|
}
|
|
|
|
func TestVerifySignature_RejectsEmptySignature(t *testing.T) {
|
|
assert.False(t, VerifySignature([]byte("payload"), "", "secret"))
|
|
}
|
|
|
|
// --------------------------------------------------------------------------- #
|
|
// TriggerJobFromCronJob
|
|
// --------------------------------------------------------------------------- #
|
|
func TestTriggerJobFromCronJob_CreatesJobMatchingTemplate(t *testing.T) {
|
|
cs := newFakeClientset(fakeCronJob("brain", "brain-sync"))
|
|
|
|
jobName, err := TriggerJobFromCronJob(context.Background(), cs, "brain", "brain-sync")
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, jobName)
|
|
|
|
jobs, err := cs.BatchV1().Jobs("brain").List(context.Background(), metav1.ListOptions{})
|
|
require.NoError(t, err)
|
|
require.Len(t, jobs.Items, 1)
|
|
assert.Equal(t, "alpine/git:v2.47.2", jobs.Items[0].Spec.Template.Spec.Containers[0].Image)
|
|
}
|
|
|
|
func TestTriggerJobFromCronJob_ErrorsWhenCronJobMissing(t *testing.T) {
|
|
cs := fake.NewSimpleClientset()
|
|
_, err := TriggerJobFromCronJob(context.Background(), cs, "brain", "brain-sync")
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
// --------------------------------------------------------------------------- #
|
|
// Handler.ServeHTTP
|
|
// --------------------------------------------------------------------------- #
|
|
func newTestHandler(cs *fake.Clientset) *Handler {
|
|
return &Handler{
|
|
Secret: "s3cret",
|
|
Clientset: cs,
|
|
Namespace: "brain",
|
|
CronJobName: "brain-sync",
|
|
WatchRepo: "mathias/brain",
|
|
Logger: testLogger(),
|
|
}
|
|
}
|
|
|
|
func pushPayload(t *testing.T, repo, ref string) []byte {
|
|
t.Helper()
|
|
body := map[string]any{
|
|
"ref": ref,
|
|
"repository": map[string]any{
|
|
"full_name": repo,
|
|
},
|
|
}
|
|
b, err := json.Marshal(body)
|
|
require.NoError(t, err)
|
|
return b
|
|
}
|
|
|
|
func TestHandler_RejectsMissingSignature(t *testing.T) {
|
|
cs := fake.NewSimpleClientset(fakeCronJob("brain", "brain-sync"))
|
|
h := newTestHandler(cs)
|
|
payload := pushPayload(t, "mathias/brain", "refs/heads/main")
|
|
req := httptest.NewRequest(http.MethodPost, "/webhooks/brain-sync", bytes.NewReader(payload))
|
|
rec := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusUnauthorized, rec.Code)
|
|
jobs, _ := cs.BatchV1().Jobs("brain").List(context.Background(), metav1.ListOptions{})
|
|
assert.Empty(t, jobs.Items, "must not trigger a job on an unsigned request")
|
|
}
|
|
|
|
func TestHandler_RejectsWrongSignature(t *testing.T) {
|
|
cs := fake.NewSimpleClientset(fakeCronJob("brain", "brain-sync"))
|
|
h := newTestHandler(cs)
|
|
payload := pushPayload(t, "mathias/brain", "refs/heads/main")
|
|
req := httptest.NewRequest(http.MethodPost, "/webhooks/brain-sync", bytes.NewReader(payload))
|
|
req.Header.Set("X-Gitea-Signature", sign(t, payload, "not-the-real-secret"))
|
|
rec := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusUnauthorized, rec.Code)
|
|
jobs, _ := cs.BatchV1().Jobs("brain").List(context.Background(), metav1.ListOptions{})
|
|
assert.Empty(t, jobs.Items)
|
|
}
|
|
|
|
func TestHandler_IgnoresOtherRepos(t *testing.T) {
|
|
cs := fake.NewSimpleClientset(fakeCronJob("brain", "brain-sync"))
|
|
h := newTestHandler(cs)
|
|
payload := pushPayload(t, "mathias/some-other-repo", "refs/heads/main")
|
|
req := httptest.NewRequest(http.MethodPost, "/webhooks/brain-sync", bytes.NewReader(payload))
|
|
req.Header.Set("X-Gitea-Signature", sign(t, payload, "s3cret"))
|
|
rec := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
jobs, _ := cs.BatchV1().Jobs("brain").List(context.Background(), metav1.ListOptions{})
|
|
assert.Empty(t, jobs.Items, "must not trigger for a push to an unrelated repo")
|
|
}
|
|
|
|
func TestHandler_IgnoresNonMainBranch(t *testing.T) {
|
|
cs := fake.NewSimpleClientset(fakeCronJob("brain", "brain-sync"))
|
|
h := newTestHandler(cs)
|
|
payload := pushPayload(t, "mathias/brain", "refs/heads/some-feature-branch")
|
|
req := httptest.NewRequest(http.MethodPost, "/webhooks/brain-sync", bytes.NewReader(payload))
|
|
req.Header.Set("X-Gitea-Signature", sign(t, payload, "s3cret"))
|
|
rec := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
jobs, _ := cs.BatchV1().Jobs("brain").List(context.Background(), metav1.ListOptions{})
|
|
assert.Empty(t, jobs.Items, "must not trigger for a push to a non-main branch")
|
|
}
|
|
|
|
func TestHandler_TriggersJobOnValidMainPush(t *testing.T) {
|
|
cs := fake.NewSimpleClientset(fakeCronJob("brain", "brain-sync"))
|
|
h := newTestHandler(cs)
|
|
payload := pushPayload(t, "mathias/brain", "refs/heads/main")
|
|
req := httptest.NewRequest(http.MethodPost, "/webhooks/brain-sync", bytes.NewReader(payload))
|
|
req.Header.Set("X-Gitea-Signature", sign(t, payload, "s3cret"))
|
|
rec := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
jobs, err := cs.BatchV1().Jobs("brain").List(context.Background(), metav1.ListOptions{})
|
|
require.NoError(t, err)
|
|
assert.Len(t, jobs.Items, 1, "a valid push to main on the watched repo must trigger exactly one job")
|
|
}
|