feat(webhook): per-project and per-site webhook URLs
Build / build (push) Successful in 10m25s

Replace the single global webhook secret with entity-scoped secrets stored
on each project and static site. Webhook-driven project autocreate is
removed — projects must exist before their URL can trigger deploys.

Also wires static-site webhooks (sync_trigger=push|tag), turning the
previously inert "push" trigger into a functional one: POST the site's
webhook URL from a Git provider and Tinyforge re-syncs on matching refs.

- Adds webhook_secret columns + unique indexes to projects and static_sites
- Per-entity GET/regenerate endpoints under /api/projects/{id}/webhook
  and /api/sites/{id}/webhook (admin-only)
- Removes /api/settings/webhook-url and the global webhook panel
- Reusable WebhookPanel Svelte component on both detail pages, i18n in en/ru
- Tests for matcher (siteRefMatches, ParseImageRef) and handler (project
  match/mismatch/404 and site push/manual/branch-skip)
This commit is contained in:
2026-04-23 15:18:19 +03:00
parent e08acf5c0e
commit 0632f512e6
21 changed files with 1119 additions and 363 deletions
+311
View File
@@ -0,0 +1,311 @@
package webhook_test
import (
"context"
"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"
)
// 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)
}
}