0405ecd9ce
Build / build (push) Successful in 10m36s
Outgoing notifications were bare POSTs with no auth and no way to verify
they came from Tinyforge. They also went out from one global URL only,
even though stages had a notification_url field, and static-site sync
emitted no events at all.
Schema: add notification_url + notification_secret (lazy-generated) to
settings, projects, stages and static_sites. Migrations are additive.
Notifier: SendSigned computes HMAC-SHA256 over the exact body bytes and
sends X-Hub-Signature-256 (GitHub-compatible — receivers built for
GitHub/Gitea/Forgejo verify out of the box). Aux headers
X-Tinyforge-Event/Delivery/Timestamp/Tier are advisory and not signed.
Empty secret => unsigned send for back-compat.
Resolution: deploys fall through stage > project > settings, sites fall
through site > settings. The secret travels with the URL that sourced
it, so any tier can sign even when its parents are unsigned. Site sync
events now actually emit (site_sync_success / site_sync_failure).
API: 12 new endpoints — {GET secret, POST regenerate, POST disable,
POST test} for each of the 4 tiers. SendSyncForTest returns
status_code/latency_ms/signature_sent/delivery_id/response_snippet so
the UI surfaces receiver feedback inline.
UI: shared OutgoingWebhookPanel.svelte fits the existing card aesthetic.
Signing-state pill, secret reveal-on-demand, regenerate/disable behind
ConfirmDialog modals (not inline strips — too easy to misclick), send-
test result card with colour-coded status. Wired into Settings →
Integrations, project edit form, per-stage edit, and per-site detail.
EN + RU i18n.
Tests: round-trip (sender signs, receiver verifies), tampered-body and
wrong-secret rejection, unsigned-send omits header, send-test surfaces
4xx, concurrent fan-out via Drain. Resolver precedence locked for both
deploy and site paths.
Docs: docs/webhooks.md with header reference, verifier snippets in
Node/Python/Go, and a recipe for the service-to-notification-bridge
generic webhook provider.
235 lines
7.4 KiB
Go
235 lines
7.4 KiB
Go
package notify_test
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/alexei/tinyforge/internal/notify"
|
|
)
|
|
|
|
// TestSignedRoundTrip is the canonical "the receiver can verify what we
|
|
// sent" check. Sender signs the body with a secret; the test server reads
|
|
// the raw body and the X-Hub-Signature-256 header and verifies via
|
|
// VerifySignature. A regression here means receivers built against our
|
|
// docs would silently reject real notifications.
|
|
func TestSignedRoundTrip(t *testing.T) {
|
|
const secret = "super-secret-test-key-not-used-in-prod"
|
|
|
|
var receivedBody []byte
|
|
var receivedSig string
|
|
var receivedEvent string
|
|
var receivedDelivery string
|
|
var receivedTier string
|
|
var receivedTimestamp string
|
|
done := make(chan struct{})
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer close(done)
|
|
body, _ := io.ReadAll(r.Body)
|
|
receivedBody = body
|
|
receivedSig = r.Header.Get(notify.HeaderSignature)
|
|
receivedEvent = r.Header.Get(notify.HeaderEvent)
|
|
receivedDelivery = r.Header.Get(notify.HeaderDelivery)
|
|
receivedTier = r.Header.Get(notify.HeaderTier)
|
|
receivedTimestamp = r.Header.Get(notify.HeaderTimestamp)
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
n := notify.New()
|
|
n.SendSigned(srv.URL, secret, notify.TierStage, notify.Event{
|
|
Type: "deploy_success",
|
|
Project: "demo",
|
|
Stage: "prod",
|
|
})
|
|
n.Drain()
|
|
<-done
|
|
|
|
if !notify.VerifySignature(secret, receivedBody, receivedSig) {
|
|
t.Fatalf("receiver could not verify signature: header=%q body=%q", receivedSig, receivedBody)
|
|
}
|
|
if receivedEvent != "deploy_success" {
|
|
t.Errorf("event header = %q, want deploy_success", receivedEvent)
|
|
}
|
|
if receivedDelivery == "" {
|
|
t.Errorf("delivery ID header missing")
|
|
}
|
|
if receivedTier != string(notify.TierStage) {
|
|
t.Errorf("tier header = %q, want %q", receivedTier, notify.TierStage)
|
|
}
|
|
if receivedTimestamp == "" {
|
|
t.Errorf("timestamp header missing")
|
|
}
|
|
|
|
// Sanity: payload roundtrips through JSON unchanged.
|
|
var got notify.Event
|
|
if err := json.Unmarshal(receivedBody, &got); err != nil {
|
|
t.Fatalf("decode body: %v", err)
|
|
}
|
|
if got.Project != "demo" || got.Stage != "prod" {
|
|
t.Errorf("body fields lost in transit: %+v", got)
|
|
}
|
|
}
|
|
|
|
// TestUnsignedSendOmitsSignatureHeader covers the back-compat path: a
|
|
// caller that hasn't yet generated a signing secret should still be able to
|
|
// dispatch events, just without the signature header. Existing receivers
|
|
// must not break when a Tinyforge instance upgrades but hasn't enabled
|
|
// signing yet.
|
|
func TestUnsignedSendOmitsSignatureHeader(t *testing.T) {
|
|
var sigHeader string
|
|
done := make(chan struct{})
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer close(done)
|
|
sigHeader = r.Header.Get(notify.HeaderSignature)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
n := notify.New()
|
|
n.SendSigned(srv.URL, "", notify.TierSettings, notify.Event{Type: "test"})
|
|
n.Drain()
|
|
<-done
|
|
|
|
if sigHeader != "" {
|
|
t.Errorf("expected no signature header on unsigned send, got %q", sigHeader)
|
|
}
|
|
}
|
|
|
|
// TestVerifyRejectsTamperedBody is the negative half of the round-trip:
|
|
// flipping a single byte in the signed body must fail verification.
|
|
// Catches accidental MAC truncation / wrong hash family / non-constant-time
|
|
// compares (the last only weakly, but the round-trip already guards
|
|
// correctness; this just locks the contract).
|
|
func TestVerifyRejectsTamperedBody(t *testing.T) {
|
|
const secret = "abc"
|
|
body := []byte(`{"type":"deploy_success"}`)
|
|
sig := "sha256=" + hexEncode(hmacSha256(secret, body))
|
|
|
|
if !notify.VerifySignature(secret, body, sig) {
|
|
t.Fatalf("control: legit signature failed to verify")
|
|
}
|
|
|
|
tampered := append([]byte(nil), body...)
|
|
tampered[1] = 'X' // flip one byte
|
|
if notify.VerifySignature(secret, tampered, sig) {
|
|
t.Errorf("verifier accepted tampered body — signature scheme is broken")
|
|
}
|
|
|
|
if notify.VerifySignature("wrong-secret", body, sig) {
|
|
t.Errorf("verifier accepted wrong secret")
|
|
}
|
|
if notify.VerifySignature(secret, body, "") {
|
|
t.Errorf("verifier accepted empty signature header")
|
|
}
|
|
if notify.VerifySignature("", body, sig) {
|
|
t.Errorf("verifier accepted empty secret")
|
|
}
|
|
}
|
|
|
|
// TestSendSyncForTestReturnsReceiverStatus is the "send test" UI button
|
|
// contract: when the receiver returns a non-2xx status, we must surface
|
|
// both the status code and the body preview rather than swallowing them.
|
|
// Operators rely on this to debug mis-pointed receivers.
|
|
func TestSendSyncForTestReturnsReceiverStatus(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_, _ = w.Write([]byte("invalid signature"))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
n := notify.New()
|
|
res := n.SendSyncForTest(context.Background(), srv.URL, "secret", notify.TierProject, notify.Event{Type: "test"})
|
|
|
|
if res.StatusCode != http.StatusForbidden {
|
|
t.Errorf("status_code = %d, want 403", res.StatusCode)
|
|
}
|
|
if res.ResponseSnippet != "invalid signature" {
|
|
t.Errorf("response_snippet = %q, want 'invalid signature'", res.ResponseSnippet)
|
|
}
|
|
if !res.SignatureSent {
|
|
t.Errorf("signature_sent should be true when secret is provided")
|
|
}
|
|
if res.Tier != notify.TierProject {
|
|
t.Errorf("tier = %q, want project", res.Tier)
|
|
}
|
|
if res.Error == "" {
|
|
t.Errorf("Error field should be set on 4xx response")
|
|
}
|
|
}
|
|
|
|
// TestSendSyncForTestEmptyURL is the guard for the test endpoint when no
|
|
// URL is configured at any tier. The handler relies on Error being non-empty
|
|
// to render the "no URL configured" message, so this contract must hold.
|
|
func TestSendSyncForTestEmptyURL(t *testing.T) {
|
|
n := notify.New()
|
|
res := n.SendSyncForTest(context.Background(), "", "secret", notify.TierSettings, notify.Event{Type: "test"})
|
|
if res.Error == "" {
|
|
t.Errorf("Error field should be set when URL is empty")
|
|
}
|
|
if res.StatusCode != 0 {
|
|
t.Errorf("StatusCode should remain 0 when no request was made, got %d", res.StatusCode)
|
|
}
|
|
}
|
|
|
|
// TestConcurrentSendsAllArrive guards the WaitGroup contract on Drain — a
|
|
// regression where Drain returns before in-flight goroutines complete would
|
|
// drop notifications during graceful shutdown.
|
|
func TestConcurrentSendsAllArrive(t *testing.T) {
|
|
const fanout = 20
|
|
var (
|
|
mu sync.Mutex
|
|
received int
|
|
)
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
mu.Lock()
|
|
received++
|
|
mu.Unlock()
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
n := notify.New()
|
|
for i := 0; i < fanout; i++ {
|
|
n.SendSigned(srv.URL, "key", notify.TierSettings, notify.Event{Type: "test"})
|
|
}
|
|
n.Drain()
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if received != fanout {
|
|
t.Errorf("received %d sends, want %d", received, fanout)
|
|
}
|
|
}
|
|
|
|
// --- helpers ----------------------------------------------------------
|
|
|
|
// hmacSha256 + hexEncode duplicate the production sign() body so the
|
|
// negative tests don't depend on the un-exported helper. If the exported
|
|
// VerifySignature contract changes, this is the canary.
|
|
func hmacSha256(secret string, body []byte) []byte {
|
|
h := hmac.New(sha256.New, []byte(secret))
|
|
h.Write(body)
|
|
return h.Sum(nil)
|
|
}
|
|
|
|
func hexEncode(b []byte) string {
|
|
const hexdigits = "0123456789abcdef"
|
|
out := make([]byte, len(b)*2)
|
|
for i, x := range b {
|
|
out[i*2] = hexdigits[x>>4]
|
|
out[i*2+1] = hexdigits[x&0x0f]
|
|
}
|
|
return string(out)
|
|
}
|