package webhook_test import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "sync" "testing" "github.com/go-chi/chi/v5" "github.com/alexei/tinyforge/internal/store" "github.com/alexei/tinyforge/internal/webhook" ) // signBody computes the HMAC-SHA256 hex digest used by the X-Hub-Signature-256 header. func signBody(secret, body string) string { mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(body)) return "sha256=" + hex.EncodeToString(mac.Sum(nil)) } // doJSONSigned mirrors doJSON but adds the X-Hub-Signature-256 header. func doJSONSigned(t *testing.T, r chi.Router, method, path, body, signingSecret string) (*http.Response, string) { t.Helper() req := httptest.NewRequest(method, path, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") if signingSecret != "" { req.Header.Set("X-Hub-Signature-256", signBody(signingSecret, body)) } w := httptest.NewRecorder() r.ServeHTTP(w, req) resp := w.Result() b, _ := io.ReadAll(resp.Body) resp.Body.Close() return resp, string(b) } // fakeDeployer records the last trigger for assertion. type fakeDeployer struct { mu sync.Mutex calls int lastProj string lastStg string lastTag string err error } func (f *fakeDeployer) TriggerDeploy(_ context.Context, projectID, stageID, tag string) error { f.mu.Lock() defer f.mu.Unlock() f.calls++ f.lastProj = projectID f.lastStg = stageID f.lastTag = tag return f.err } // fakeSiteTriggerer records Deploy calls. type fakeSiteTriggerer struct { mu sync.Mutex calls int done chan struct{} } func (f *fakeSiteTriggerer) Deploy(_ context.Context, _ string, _ bool) error { f.mu.Lock() f.calls++ ch := f.done f.mu.Unlock() if ch != nil { select { case ch <- struct{}{}: default: } } return nil } func newRouter(t *testing.T, h *webhook.Handler) chi.Router { t.Helper() r := chi.NewRouter() r.Mount("/api/webhook", h.Route()) return r } func newStore(t *testing.T) *store.Store { t.Helper() s, err := store.New(":memory:") if err != nil { t.Fatalf("create store: %v", err) } t.Cleanup(func() { s.Close() }) return s } func doJSON(t *testing.T, r chi.Router, method, path, body string) (*http.Response, string) { t.Helper() req := httptest.NewRequest(method, path, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) resp := w.Result() b, _ := io.ReadAll(resp.Body) resp.Body.Close() return resp, string(b) } func TestProjectWebhook_UnknownSecretReturns404(t *testing.T) { t.Parallel() st := newStore(t) h := webhook.NewHandler(st, &fakeDeployer{}, nil) r := newRouter(t, h) resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/bogus-secret", `{"image":"x"}`) if resp.StatusCode != http.StatusNotFound { t.Errorf("expected 404, got %d", resp.StatusCode) } } func TestProjectWebhook_DeploysOnMatchingStage(t *testing.T) { t.Parallel() st := newStore(t) p, err := st.CreateProject(store.Project{ Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}", }) if err != nil { t.Fatalf("create project: %v", err) } stage, err := st.CreateStage(store.Stage{ ProjectID: p.ID, Name: "dev", TagPattern: "dev-*", AutoDeploy: true, MaxInstances: 1, }) if err != nil { t.Fatalf("create stage: %v", err) } dep := &fakeDeployer{} h := webhook.NewHandler(st, dep, nil) r := newRouter(t, h) path := "/api/webhook/" + p.WebhookSecret resp, body := doJSON(t, r, http.MethodPost, path, `{"image":"alexei/app:dev-abc"}`) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body) } if dep.calls != 1 { t.Fatalf("expected 1 deploy call, got %d", dep.calls) } if dep.lastProj != p.ID || dep.lastStg != stage.ID || dep.lastTag != "dev-abc" { t.Errorf("deploy called with wrong args: proj=%s stage=%s tag=%s", dep.lastProj, dep.lastStg, dep.lastTag) } } func TestProjectWebhook_ImageMismatchRejected(t *testing.T) { t.Parallel() st := newStore(t) p, err := st.CreateProject(store.Project{ Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}", }) if err != nil { t.Fatalf("create project: %v", err) } if _, err := st.CreateStage(store.Stage{ ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: true, MaxInstances: 1, }); err != nil { t.Fatalf("create stage: %v", err) } dep := &fakeDeployer{} h := webhook.NewHandler(st, dep, nil) r := newRouter(t, h) resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret, `{"image":"otheruser/other:dev"}`) if resp.StatusCode != http.StatusBadRequest { t.Errorf("expected 400 on image mismatch, got %d", resp.StatusCode) } if dep.calls != 0 { t.Errorf("deploy should not have been triggered on image mismatch") } } func TestProjectWebhook_NoMatchingStageReturns200NoDeploy(t *testing.T) { t.Parallel() st := newStore(t) p, err := st.CreateProject(store.Project{ Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}", }) if err != nil { t.Fatalf("create project: %v", err) } if _, err := st.CreateStage(store.Stage{ ProjectID: p.ID, Name: "prod", TagPattern: "v*", AutoDeploy: true, MaxInstances: 1, }); err != nil { t.Fatalf("create stage: %v", err) } dep := &fakeDeployer{} h := webhook.NewHandler(st, dep, nil) r := newRouter(t, h) resp, body := doJSON(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret, `{"image":"alexei/app:dev-abc"}`) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body) } if dep.calls != 0 { t.Errorf("expected no deploy call, got %d", dep.calls) } var parsed map[string]any if err := json.Unmarshal([]byte(body), &parsed); err != nil { t.Fatalf("response is not JSON: %v", err) } if parsed["deploy"] != false { t.Errorf("expected deploy=false, got %v", parsed["deploy"]) } } func TestProjectWebhook_AutoDeployDisabled(t *testing.T) { t.Parallel() st := newStore(t) p, _ := st.CreateProject(store.Project{Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}"}) _, _ = st.CreateStage(store.Stage{ ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: false, MaxInstances: 1, }) dep := &fakeDeployer{} h := webhook.NewHandler(st, dep, nil) r := newRouter(t, h) resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret, `{"image":"alexei/app:dev-1"}`) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if dep.calls != 0 { t.Errorf("auto_deploy=false should suppress deploy call; got %d", dep.calls) } } func TestSiteWebhook_UnknownSecretReturns404(t *testing.T) { t.Parallel() st := newStore(t) h := webhook.NewHandler(st, &fakeDeployer{}, &fakeSiteTriggerer{}) r := newRouter(t, h) resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/sites/bogus", "{}") if resp.StatusCode != http.StatusNotFound { t.Errorf("expected 404, got %d", resp.StatusCode) } } func TestSiteWebhook_ManualTriggerShortCircuits(t *testing.T) { t.Parallel() st := newStore(t) site, err := st.CreateStaticSite(store.StaticSite{ Name: "docs", GiteaURL: "https://git.example", RepoOwner: "x", RepoName: "y", Branch: "main", SyncTrigger: "manual", Status: "idle", }) if err != nil { t.Fatalf("create site: %v", err) } ft := &fakeSiteTriggerer{} h := webhook.NewHandler(st, &fakeDeployer{}, ft) r := newRouter(t, h) resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/sites/"+site.WebhookSecret, `{"ref":"refs/heads/main"}`) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if ft.calls != 0 { t.Errorf("manual-trigger site must not invoke sync; got %d calls", ft.calls) } } func TestSiteWebhook_PushTriggersSyncOnBranchMatch(t *testing.T) { t.Parallel() st := newStore(t) site, err := st.CreateStaticSite(store.StaticSite{ Name: "docs", GiteaURL: "https://git.example", RepoOwner: "x", RepoName: "y", Branch: "main", SyncTrigger: "push", Status: "idle", }) if err != nil { t.Fatalf("create site: %v", err) } ft := &fakeSiteTriggerer{done: make(chan struct{}, 1)} h := webhook.NewHandler(st, &fakeDeployer{}, ft) r := newRouter(t, h) resp, body := doJSON(t, r, http.MethodPost, "/api/webhook/sites/"+site.WebhookSecret, `{"ref":"refs/heads/main"}`) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body) } // Sync runs in a goroutine — wait for the signal. <-ft.done ft.mu.Lock() calls := ft.calls ft.mu.Unlock() if calls != 1 { t.Errorf("expected 1 sync call, got %d", calls) } } func TestSiteWebhook_PushSkippedForNonMatchingBranch(t *testing.T) { t.Parallel() st := newStore(t) site, _ := st.CreateStaticSite(store.StaticSite{ Name: "docs", GiteaURL: "https://git.example", RepoOwner: "x", RepoName: "y", Branch: "main", SyncTrigger: "push", Status: "idle", }) ft := &fakeSiteTriggerer{} h := webhook.NewHandler(st, &fakeDeployer{}, ft) r := newRouter(t, h) resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/sites/"+site.WebhookSecret, `{"ref":"refs/heads/feature-x"}`) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if ft.calls != 0 { t.Errorf("non-matching branch must not trigger sync; got %d calls", ft.calls) } } // HMAC enforcement scenarios. func TestProjectWebhook_HMACRequiredAndValid(t *testing.T) { t.Parallel() st := newStore(t) p, _ := st.CreateProject(store.Project{ Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}", }) if _, err := st.CreateStage(store.Stage{ ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: true, MaxInstances: 1, }); err != nil { t.Fatal(err) } const sig = "deadbeef-signing-secret-1234567890abcdef" if err := st.SetProjectWebhookSigningSecret(p.ID, sig); err != nil { t.Fatal(err) } if err := st.SetProjectWebhookRequireSignature(p.ID, true); err != nil { t.Fatal(err) } dep := &fakeDeployer{} h := webhook.NewHandler(st, dep, nil) r := newRouter(t, h) body := `{"image":"alexei/app:dev-abc"}` resp, msg := doJSONSigned(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret, body, sig) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200 with valid sig, got %d: %s", resp.StatusCode, msg) } if dep.calls != 1 { t.Errorf("valid signed deploy should fire once, got %d", dep.calls) } } func TestProjectWebhook_HMACRequiredButMissing(t *testing.T) { t.Parallel() st := newStore(t) p, _ := st.CreateProject(store.Project{ Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}", }) if _, err := st.CreateStage(store.Stage{ ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: true, MaxInstances: 1, }); err != nil { t.Fatal(err) } if err := st.SetProjectWebhookSigningSecret(p.ID, "abc-signing-secret-12345678901234567890"); err != nil { t.Fatal(err) } if err := st.SetProjectWebhookRequireSignature(p.ID, true); err != nil { t.Fatal(err) } dep := &fakeDeployer{} h := webhook.NewHandler(st, dep, nil) r := newRouter(t, h) resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret, `{"image":"alexei/app:dev-abc"}`) if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("missing signature must return 401 when required, got %d", resp.StatusCode) } if dep.calls != 0 { t.Errorf("deploy must not fire when required signature is missing") } } func TestProjectWebhook_HMACPresentButWrong(t *testing.T) { t.Parallel() st := newStore(t) p, _ := st.CreateProject(store.Project{ Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}", }) if _, err := st.CreateStage(store.Stage{ ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: true, MaxInstances: 1, }); err != nil { t.Fatal(err) } if err := st.SetProjectWebhookSigningSecret(p.ID, "real-signing-secret-1234567890abcdef"); err != nil { t.Fatal(err) } // Note: require_signature stays false — but a wrong sig must still 401. dep := &fakeDeployer{} h := webhook.NewHandler(st, dep, nil) r := newRouter(t, h) resp, _ := doJSONSigned(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret, `{"image":"alexei/app:dev-abc"}`, "wrong-secret-xxxxxxxxxxxxxxxxxxxxxxxxxxxx") if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("wrong signature must 401, got %d", resp.StatusCode) } if dep.calls != 0 { t.Errorf("deploy must not fire on wrong signature") } } func TestProjectWebhook_HMACOptionalUnsignedAccepted(t *testing.T) { // require_signature=false AND signing_secret="": unsigned requests pass. t.Parallel() st := newStore(t) p, _ := st.CreateProject(store.Project{ Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}", }) if _, err := st.CreateStage(store.Stage{ ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: true, MaxInstances: 1, }); err != nil { t.Fatal(err) } dep := &fakeDeployer{} h := webhook.NewHandler(st, dep, nil) r := newRouter(t, h) resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret, `{"image":"alexei/app:dev-x"}`) if resp.StatusCode != http.StatusOK { t.Fatalf("unsigned + unconfigured should pass, got %d", resp.StatusCode) } if dep.calls != 1 { t.Errorf("expected 1 deploy, got %d", dep.calls) } }