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 := "" 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") } }