http: /settings/notifications CRUD + test endpoint
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
stdhttp "net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/auth"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/crypto"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/notification"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/config"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||
)
|
||||
|
||||
// newNotificationTestServer builds a test server wired with a real
|
||||
// NotificationHub backed by a temporary store. It also inserts a session
|
||||
// so HTTP calls are authenticated.
|
||||
func newNotificationTestServer(t *testing.T) (*Server, string, *store.Store, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
st, err := store.Open(context.Background(), filepath.Join(dir, "rm.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
keyPath := filepath.Join(dir, "secret.key")
|
||||
_ = crypto.GenerateKeyFile(keyPath)
|
||||
key, _ := crypto.LoadKeyFromFile(keyPath)
|
||||
aead, _ := crypto.NewAEAD(key)
|
||||
|
||||
hub := notification.NewHub(st, aead, "http://localhost")
|
||||
|
||||
deps := Deps{
|
||||
Cfg: config.Config{Listen: ":0", DataDir: dir, SecretKeyFile: keyPath},
|
||||
Store: st,
|
||||
AEAD: aead,
|
||||
Hub: ws.NewHub(),
|
||||
NotificationHub: hub,
|
||||
BootstrapToken: "test-token",
|
||||
}
|
||||
s := New(deps)
|
||||
ts := httptest.NewServer(s.srv.Handler)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
// Mint a user + session so authenticated routes work.
|
||||
rawToken, _ := auth.NewToken()
|
||||
userID := ulid.Make().String()
|
||||
hash, _ := auth.HashPassword("test-password-long")
|
||||
_ = st.CreateUser(context.Background(), store.User{
|
||||
ID: userID,
|
||||
Username: "testadmin",
|
||||
PasswordHash: hash,
|
||||
Role: store.RoleAdmin,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
})
|
||||
_ = st.CreateSession(context.Background(), store.Session{
|
||||
UserID: userID,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ExpiresAt: time.Now().Add(time.Hour).UTC(),
|
||||
}, auth.HashToken(rawToken))
|
||||
|
||||
return s, ts.URL, st, rawToken
|
||||
}
|
||||
|
||||
// authedClient returns a client + cookie jar that sends the test session cookie.
|
||||
func authedClient(t *testing.T, rawToken string, baseURL string) *stdhttp.Client {
|
||||
t.Helper()
|
||||
jar := &simpleCookieJar{token: rawToken, baseURL: baseURL}
|
||||
return &stdhttp.Client{Jar: jar}
|
||||
}
|
||||
|
||||
// simpleCookieJar injects the session cookie on every request to baseURL.
|
||||
type simpleCookieJar struct {
|
||||
token string
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func (j *simpleCookieJar) SetCookies(_ *url.URL, _ []*stdhttp.Cookie) {}
|
||||
|
||||
func (j *simpleCookieJar) Cookies(u *url.URL) []*stdhttp.Cookie {
|
||||
if !strings.HasPrefix(u.String(), j.baseURL) {
|
||||
return nil
|
||||
}
|
||||
return []*stdhttp.Cookie{{Name: sessionCookieName, Value: j.token}}
|
||||
}
|
||||
|
||||
// createTestWebhookChannel inserts a webhook channel into the store
|
||||
// for the given server's AEAD, targeting sink.
|
||||
func createTestWebhookChannel(t *testing.T, s *Server, st *store.Store, sink string) string {
|
||||
t.Helper()
|
||||
id := "ch-test-" + strings.ReplaceAll(t.Name(), "/", "-")
|
||||
cfg, _ := json.Marshal(notification.WebhookConfig{URL: sink})
|
||||
enc, err := s.deps.AEAD.Encrypt(cfg, []byte("notification-channel:"+id))
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt: %v", err)
|
||||
}
|
||||
err = st.CreateNotificationChannel(context.Background(), store.NotificationChannel{
|
||||
ID: id,
|
||||
Kind: "webhook",
|
||||
Name: "test-webhook",
|
||||
Enabled: true,
|
||||
Config: []byte(enc),
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// TestAPINotificationTestEndToEnd is the primary plan test:
|
||||
// configure a webhook channel pointing at an httptest sink, POST the
|
||||
// test endpoint, assert the synthetic event landed at the sink and a
|
||||
// notification_log row with event="alert.test" ok=1 was persisted.
|
||||
func TestAPINotificationTestEndToEnd(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Sink — records incoming request bodies.
|
||||
var received [][]byte
|
||||
sink := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
received = append(received, body)
|
||||
w.WriteHeader(stdhttp.StatusOK)
|
||||
}))
|
||||
defer sink.Close()
|
||||
|
||||
s, baseURL, st, rawToken := newNotificationTestServer(t)
|
||||
channelID := createTestWebhookChannel(t, s, st, sink.URL)
|
||||
client := authedClient(t, rawToken, baseURL)
|
||||
|
||||
res, err := client.Post(baseURL+"/api/notifications/"+channelID+"/test",
|
||||
"application/json", bytes.NewReader(nil))
|
||||
if err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
if res.StatusCode != stdhttp.StatusOK {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
t.Fatalf("status %d: %s", res.StatusCode, body)
|
||||
}
|
||||
|
||||
var result testResultFragment
|
||||
if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !result.OK {
|
||||
errStr := "<nil>"
|
||||
if result.Error != nil {
|
||||
errStr = *result.Error
|
||||
}
|
||||
t.Fatalf("expected ok=true, got false; error=%s", errStr)
|
||||
}
|
||||
|
||||
// The sink should have received exactly one request.
|
||||
if len(received) != 1 {
|
||||
t.Fatalf("sink: expected 1 request, got %d", len(received))
|
||||
}
|
||||
|
||||
// Decode the webhook body and check the event field.
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(received[0], &body); err != nil {
|
||||
t.Fatalf("decode sink body: %v", err)
|
||||
}
|
||||
if body["event"] != string(notification.EventTest) {
|
||||
t.Errorf("event: got %v, want %s", body["event"], notification.EventTest)
|
||||
}
|
||||
|
||||
// notification_log should have one row with event=alert.test and ok=1.
|
||||
var n int
|
||||
if err := st.DB().QueryRow(
|
||||
`SELECT COUNT(*) FROM notification_log
|
||||
WHERE channel_id = ? AND event = 'alert.test' AND ok = 1`,
|
||||
channelID,
|
||||
).Scan(&n); err != nil {
|
||||
t.Fatalf("query log: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("notification_log: expected 1 row, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPINotificationTestNotFound confirms a 404 for an unknown channel.
|
||||
func TestAPINotificationTestNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, baseURL, _, rawToken := newNotificationTestServer(t)
|
||||
client := authedClient(t, rawToken, baseURL)
|
||||
|
||||
res, err := client.Post(baseURL+"/api/notifications/no-such-channel/test",
|
||||
"application/json", bytes.NewReader(nil))
|
||||
if err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
if res.StatusCode != stdhttp.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", res.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPINotificationTestUnauthed confirms a redirect (or 4xx) when
|
||||
// there is no session cookie.
|
||||
func TestAPINotificationTestUnauthed(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, baseURL, _, _ := newNotificationTestServer(t)
|
||||
|
||||
// Use a client that does NOT follow redirects and has no cookie.
|
||||
client := &stdhttp.Client{
|
||||
CheckRedirect: func(_ *stdhttp.Request, _ []*stdhttp.Request) error {
|
||||
return stdhttp.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
res, err := client.Post(baseURL+"/api/notifications/any-id/test",
|
||||
"application/json", bytes.NewReader(nil))
|
||||
if err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
// requireUIUser redirects to /login for unauthenticated requests.
|
||||
if res.StatusCode != stdhttp.StatusSeeOther && res.StatusCode != stdhttp.StatusUnauthorized {
|
||||
t.Errorf("expected 303 or 401, got %d", res.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotificationCreateAndDelete is a CRUD round-trip exercising
|
||||
// the store methods. The handler layer would return template errors
|
||||
// (no templates in tests), so we exercise just the store-level API
|
||||
// that the handlers call, confirming the plumbing compiles and works.
|
||||
func TestNotificationCreateAndDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
s, _, st, _ := newNotificationTestServer(t)
|
||||
|
||||
id := "ch-crud-test"
|
||||
cfg, _ := json.Marshal(notification.WebhookConfig{URL: "https://example.com/hook"})
|
||||
enc, _ := s.deps.AEAD.Encrypt(cfg, []byte("notification-channel:"+id))
|
||||
|
||||
now := time.Now().UTC()
|
||||
err := st.CreateNotificationChannel(context.Background(), store.NotificationChannel{
|
||||
ID: id,
|
||||
Kind: "webhook",
|
||||
Name: "crud-test",
|
||||
Enabled: true,
|
||||
Config: []byte(enc),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
// Read it back and decrypt.
|
||||
ch, err := st.GetNotificationChannel(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
var got notification.WebhookConfig
|
||||
plain, err := s.deps.AEAD.Decrypt(string(ch.Config), []byte("notification-channel:"+id))
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(plain, &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if got.URL != "https://example.com/hook" {
|
||||
t.Errorf("URL: got %q, want %q", got.URL, "https://example.com/hook")
|
||||
}
|
||||
|
||||
// Delete.
|
||||
if err := st.DeleteNotificationChannel(context.Background(), id); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if _, err := st.GetNotificationChannel(context.Background(), id); err == nil {
|
||||
t.Error("expected ErrNotFound after delete")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user