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:
@@ -0,0 +1,98 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
func TestSiteRefMatches_Push(t *testing.T) {
|
||||
t.Parallel()
|
||||
site := store.StaticSite{SyncTrigger: "push", Branch: "main"}
|
||||
cases := []struct {
|
||||
ref string
|
||||
want bool
|
||||
}{
|
||||
{"refs/heads/main", true},
|
||||
{"refs/heads/develop", false},
|
||||
{"refs/tags/v1.0.0", false},
|
||||
{"", false},
|
||||
{"main", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := siteRefMatches(site, tc.ref); got != tc.want {
|
||||
t.Errorf("siteRefMatches(push, %q) = %v; want %v", tc.ref, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteRefMatches_PushEmptyBranchAcceptsAny(t *testing.T) {
|
||||
t.Parallel()
|
||||
// When Branch is unset, any heads ref should match — tolerates the sites
|
||||
// table having blank Branch values from legacy rows.
|
||||
site := store.StaticSite{SyncTrigger: "push"}
|
||||
if !siteRefMatches(site, "refs/heads/whatever") {
|
||||
t.Error("expected empty Branch to accept any heads ref")
|
||||
}
|
||||
if siteRefMatches(site, "refs/tags/v1") {
|
||||
t.Error("empty Branch must still reject tag refs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteRefMatches_Tag(t *testing.T) {
|
||||
t.Parallel()
|
||||
site := store.StaticSite{SyncTrigger: "tag", TagPattern: "v*"}
|
||||
cases := []struct {
|
||||
ref string
|
||||
want bool
|
||||
}{
|
||||
{"refs/tags/v1.0.0", true},
|
||||
{"refs/tags/v2", true},
|
||||
{"refs/tags/hotfix", false},
|
||||
{"refs/heads/main", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := siteRefMatches(site, tc.ref); got != tc.want {
|
||||
t.Errorf("siteRefMatches(tag, %q) = %v; want %v", tc.ref, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteRefMatches_ManualIsIgnored(t *testing.T) {
|
||||
t.Parallel()
|
||||
site := store.StaticSite{SyncTrigger: "manual", Branch: "main"}
|
||||
if siteRefMatches(site, "refs/heads/main") {
|
||||
t.Error("manual trigger must never match any ref — caller short-circuits")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImageRef(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
in string
|
||||
wantFull string
|
||||
wantTag string
|
||||
}{
|
||||
{"registry.example.com/alexei/app:v1", "alexei/app", "v1"},
|
||||
{"alexei/app:dev", "alexei/app", "dev"},
|
||||
{"app", "app", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := ParseImageRef(tc.in)
|
||||
if err != nil {
|
||||
t.Errorf("ParseImageRef(%q) unexpected error: %v", tc.in, err)
|
||||
continue
|
||||
}
|
||||
if got.FullName() != tc.wantFull || got.Tag != tc.wantTag {
|
||||
t.Errorf("ParseImageRef(%q) = %q:%q; want %q:%q",
|
||||
tc.in, got.FullName(), got.Tag, tc.wantFull, tc.wantTag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImageRef_Empty(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := ParseImageRef(""); err == nil {
|
||||
t.Error("expected error for empty image ref")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user