diff --git a/cmd/server/main.go b/cmd/server/main.go index ba295cd..20f434e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -17,6 +17,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" @@ -156,6 +157,13 @@ func run(cfg config.Config, logger *slog.Logger) error { if err := notifier.Register(notify.ChannelPush, pushSender); err != nil { return err } + chatSvc, err := chat.NewService(db, box, cfg.PublicURL, http.DefaultTransport) + if err != nil { + return err + } + if err := notifier.Register(notify.ChannelChat, chatSvc); err != nil { + return err + } sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax) if err != nil { @@ -173,7 +181,7 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } - router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, runner, photos, frontend.Assets()) + router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, runner, photos, frontend.Assets()) if err != nil { return err } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5971111..7e1ccff 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -684,6 +684,46 @@ Runs are recorded in `schedule_runs` and executed through `jobs`. `{"endpoint"}` and answers 204 whether or not it was stored. - An invalid body is 400 `invalid_request` (not a JSON object of the known fields) or `invalid_subscription` (a field fails validation). +- `internal/chat` keeps the Google Chat incoming webhook in `chat_settings` + and is the outbox's sender for the `chat` channel. The webhook URL holds + its own key and token, so it is sealed with `secretbox` in + `webhook_url_ct` and never appears in a response, log line or error. + Under the signed-in `/api/v1`: + - `PUT /api/v1/settings/chat-webhook` (CSRF-checked) takes + `{"url", "space_label"}`, both required. The URL must be exactly + `https://chat.googleapis.com/v1/spaces/{space}/messages?key=…&token=…`: + that scheme and host spelled so, no port or user info, the space id + letters, digits, `-` or `_`, and only the query parameters `key` and + `token`, each once and at least 16 characters. The space label is 1–100 + characters. A refused body is 400 `invalid_request` (not a JSON object + of the two fields) or `invalid_webhook`. Saving replaces any stored + webhook and clears its test outcome. + - `GET /api/v1/settings/chat-webhook` answers `{configured, space_label, + url_masked, last_test_at, last_test_status, updated_at}`; `url_masked` + shows the space id and only the last four characters of the decoded + token (`…/spaces/AAQA…/messages?key=…&token=…ZxQ=`). Absent values are + `null`. + - `POST /api/v1/settings/chat-webhook/test` (CSRF-checked) posts + "MealQuest · test message", unthreaded, and records `last_test_at` and + `last_test_status` (`ok` for 2xx, else `failed`). Whatever status Google + Chat answers, it answers 200 with that status as `http_status` and the + settings as GET gives them. No webhook stored is 409 `not_configured`; + a webhook replaced during the test is 409 `webhook_changed`; no answer + from Google Chat is recorded as failed and answered 502 + `chat_unreachable`. + - A notification is posted as `{"text"}`, plus `cardsV2` with an "Open" + button to the payload's path under `MEALQUEST_PUBLIC_URL` for + `plan_ready` and `list_ready`, with `threadKey=mealquest-week-YYYY-Www` + (the ISO week of the payload's week) and + `messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD`; a payload + with no week is posted unthreaded. 2xx is sent; 429, 5xx or no answer + is retried by the outbox; any other status, or no webhook stored, fails + the row at once (`notify.ErrUndeliverable`). Redirects are not + followed. + - The builders in `internal/chat/messages.go` make each event's + `notify.Event` (title and body shared with Web Push) and `chat.Text` + its Chat message, such as "This week's plan is ready: Tacos, Bolognese, + Tikka masala + 4 more. Open the plan →". ## Google Calendar diff --git a/internal/chat/chat.go b/internal/chat/chat.go new file mode 100644 index 0000000..4cd09ef --- /dev/null +++ b/internal/chat/chat.go @@ -0,0 +1,459 @@ +// Package chat sends notifications to the household's Google Chat space +// through its incoming webhook, and keeps that webhook in chat_settings. +// +// The webhook URL carries its own key and token, so it is a secret: it is +// sealed with secretbox in webhook_url_ct and is never returned, logged or +// put in an error. Only its space label, a masked tail and the outcome of +// the last test message are shown. +package chat + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + "unicode/utf8" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +const ( + // MaxSpaceLabelChars bounds a space label. + MaxSpaceLabelChars = 100 + // requestTimeout bounds one post to Google Chat. + requestTimeout = 15 * time.Second + // maxDrainBytes bounds how much of a Google Chat response is read. + maxDrainBytes = 4096 + // replyOption threads a message under its threadKey, starting the + // thread when there is none yet. + replyOption = "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" +) + +// Test outcomes, as chat_settings.last_test_status stores them. +const ( + TestOK = "ok" + TestFailed = "failed" +) + +var ( + // ErrNotConfigured is returned when no webhook is stored. + ErrNotConfigured = errors.New("chat: no Google Chat webhook is configured") + // ErrUnreachable is wrapped by the error of a post Google Chat did not + // answer. + ErrUnreachable = errors.New("chat: Google Chat did not answer") + // ErrWebhookChanged is returned by Test when the webhook was replaced + // while the test message was being sent; nothing is recorded. + ErrWebhookChanged = errors.New("chat: the Google Chat webhook changed during the test") +) + +// Settings is the stored webhook as the API shows it: never the URL, only +// its masked form. +type Settings struct { + Configured bool `json:"configured"` + SpaceLabel *string `json:"space_label"` + // URLMasked is the webhook URL without its key and with only the last + // few characters of its token. + URLMasked *string `json:"url_masked"` + // LastTestAt and LastTestStatus ("ok" or "failed") are nil until a + // test message is sent to this webhook. + LastTestAt *string `json:"last_test_at"` + LastTestStatus *string `json:"last_test_status"` + UpdatedAt *string `json:"updated_at"` +} + +// TestResult is the outcome of a test message. +type TestResult struct { + // HTTPStatus is the status Google Chat answered. + HTTPStatus int `json:"http_status"` + Settings +} + +// Problem is one invalid field of a webhook update, named by its JSON key. +type Problem struct { + Field string + Reason string +} + +// ValidationError lists every problem with a webhook update. It never +// quotes the URL. +type ValidationError struct { + Problems []Problem +} + +func (e *ValidationError) Error() string { + parts := make([]string, len(e.Problems)) + for i, p := range e.Problems { + parts[i] = p.Field + ": " + p.Reason + } + return "invalid Google Chat webhook: " + strings.Join(parts, "; ") +} + +// Service keeps the webhook and posts to it. It is the notify.Sender of the +// chat channel. +type Service struct { + st *store.Store + box *secretbox.Box + // publicURL is MEALQUEST_PUBLIC_URL, which a card's "Open" button + // links under. + publicURL *url.URL + client *http.Client + // now is the clock settings are saved and tests recorded on; tests + // replace it. + now func() time.Time +} + +// NewService returns the service over st, sealing the webhook with box and +// linking cards under publicURL. Requests go through transport +// (http.DefaultTransport in production) and never follow a redirect. +func NewService(st *store.Store, box *secretbox.Box, publicURL *url.URL, transport http.RoundTripper) (*Service, error) { + switch { + case st == nil: + return nil, errors.New("chat: nil store") + case box == nil: + return nil, errors.New("chat: nil secret box") + case publicURL == nil: + return nil, errors.New("chat: nil public URL") + case transport == nil: + return nil, errors.New("chat: nil transport") + } + return &Service{ + st: st, + box: box, + publicURL: publicURL, + client: &http.Client{ + Transport: transport, + Timeout: requestTimeout, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + now: time.Now, + }, nil +} + +// Get returns the stored webhook's settings; Configured is false and every +// other field nil before one is saved. +func (s *Service) Get(ctx context.Context) (Settings, error) { + row, err := db.New(s.st.Reader).GetChatSettings(ctx) + if errors.Is(err, sql.ErrNoRows) { + return Settings{}, nil + } + if err != nil { + return Settings{}, fmt.Errorf("chat: read chat settings: %w", err) + } + hook, err := s.open(row) + if err != nil { + return Settings{}, err + } + return settingsFrom(row, hook), nil +} + +// Put validates rawURL and spaceLabel and stores them, replacing any +// webhook stored before and clearing its test outcome. Every problem is +// reported in a *ValidationError. +func (s *Service) Put(ctx context.Context, rawURL, spaceLabel string) (Settings, error) { + var problems []Problem + hook, err := ParseWebhook(rawURL) + if err != nil { + problems = append(problems, Problem{"url", err.Error()}) + } + label := strings.TrimSpace(spaceLabel) + switch { + case label == "": + problems = append(problems, Problem{"space_label", "is required"}) + case utf8.RuneCountInString(label) > MaxSpaceLabelChars: + problems = append(problems, Problem{"space_label", fmt.Sprintf("must be at most %d characters", MaxSpaceLabelChars)}) + case strings.ContainsFunc(label, func(r rune) bool { return r < ' ' || r == 0x7f }): + problems = append(problems, Problem{"space_label", "must not contain control characters"}) + } + if len(problems) > 0 { + return Settings{}, &ValidationError{Problems: problems} + } + ct, err := s.box.Encrypt([]byte(hook.String())) + if err != nil { + return Settings{}, fmt.Errorf("chat: seal webhook: %w", err) + } + + var saved db.ChatSetting + err = s.st.WithTx(ctx, func(tx *sql.Tx) error { + q := db.New(tx) + row, err := q.GetChatSettings(ctx) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("chat: read chat settings: %w", err) + } + now := s.timestamp() + created := now + if err == nil { + created = row.CreatedAt + } + saved, err = q.UpsertChatSettings(ctx, db.UpsertChatSettingsParams{ + WebhookUrlCt: ct, + SpaceLabel: sql.NullString{String: label, Valid: true}, + CreatedAt: created, + UpdatedAt: now, + }) + if err != nil { + return fmt.Errorf("chat: save chat settings: %w", err) + } + return nil + }) + if err != nil { + return Settings{}, err + } + return settingsFrom(saved, hook), nil +} + +// Test posts TestText to the stored webhook, unthreaded, and records when +// and whether Google Chat accepted it. A status Google Chat answers is a +// TestResult, whatever it is; a request that gets no answer is recorded as +// failed and returned as an error. A test cut short by ctx records nothing. +func (s *Service) Test(ctx context.Context) (TestResult, error) { + row, err := db.New(s.st.Reader).GetChatSettings(ctx) + if errors.Is(err, sql.ErrNoRows) { + return TestResult{}, ErrNotConfigured + } + if err != nil { + return TestResult{}, fmt.Errorf("chat: read chat settings: %w", err) + } + hook, err := s.open(row) + if err != nil { + return TestResult{}, err + } + status, postErr := s.post(ctx, hook, message{Text: TestText}, "") + if err := ctx.Err(); err != nil { + return TestResult{}, err + } + outcome := TestFailed + if postErr == nil && status >= 200 && status < 300 { + outcome = TestOK + } + at := s.timestamp() + write, cancel := jobs.FinalWriteContext(ctx) + defer cancel() + n, err := db.New(s.st.Writer).RecordChatTest(write, db.RecordChatTestParams{ + LastTestAt: sql.NullString{String: at, Valid: true}, + LastTestStatus: sql.NullString{String: outcome, Valid: true}, + UpdatedAt: at, + WebhookUrlCt: row.WebhookUrlCt, + }) + if err != nil { + return TestResult{}, fmt.Errorf("chat: record test: %w", err) + } + if n != 1 { + return TestResult{}, ErrWebhookChanged + } + if postErr != nil { + return TestResult{}, postErr + } + row.LastTestAt = sql.NullString{String: at, Valid: true} + row.LastTestStatus = sql.NullString{String: outcome, Valid: true} + row.UpdatedAt = at + return TestResult{HTTPStatus: status, Settings: settingsFrom(row, hook)}, nil +} + +// Send posts m to the stored webhook, threaded by the week of its payload, +// and is the chat channel's notify.Sender. Google Chat answering 2xx is +// success; 429 or 5xx, or no answer at all, is an error the outbox +// retries; any other status, or no webhook stored, wraps +// notify.ErrUndeliverable so the row fails at once. +func (s *Service) Send(ctx context.Context, m notify.Message) error { + row, err := db.New(s.st.Reader).GetChatSettings(ctx) + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("%w: %w", notify.ErrUndeliverable, ErrNotConfigured) + } + if err != nil { + return fmt.Errorf("chat: read chat settings: %w", err) + } + hook, err := s.open(row) + if err != nil { + return err + } + msg, err := s.messageOf(m.Payload) + if err != nil { + return fmt.Errorf("%w: chat: %w", notify.ErrUndeliverable, err) + } + var thread string + if m.Payload.Week != "" { + week, err := store.ParseLocalDate(m.Payload.Week) + if err != nil { + return fmt.Errorf("%w: chat: payload week: %w", notify.ErrUndeliverable, err) + } + thread = ThreadKey(week) + } + status, err := s.post(ctx, hook, msg, thread) + switch { + case err != nil: + return err + case status >= 200 && status < 300: + return nil + case status == http.StatusTooManyRequests || status >= 500: + return fmt.Errorf("chat: Google Chat answered status %d", status) + default: + return fmt.Errorf("%w: chat: Google Chat refused the message with status %d", notify.ErrUndeliverable, status) + } +} + +// ThreadKey is the Chat thread of the week starting on week, such as +// "mealquest-week-2026-W42": every message about a week goes in one thread. +func ThreadKey(week store.LocalDate) string { + return "mealquest-week-" + household.PeriodKey(week) +} + +// message is the body of a Google Chat webhook post. +type message struct { + Text string `json:"text"` + CardsV2 []cardV2 `json:"cardsV2,omitempty"` +} + +type cardV2 struct { + CardID string `json:"cardId"` + Card card `json:"card"` +} + +type card struct { + Header cardHeader `json:"header"` + Sections []cardSection `json:"sections"` +} + +type cardHeader struct { + Title string `json:"title"` + Subtitle string `json:"subtitle,omitempty"` +} + +type cardSection struct { + Widgets []cardWidget `json:"widgets"` +} + +type cardWidget struct { + ButtonList buttonList `json:"buttonList"` +} + +type buttonList struct { + Buttons []button `json:"buttons"` +} + +type button struct { + Text string `json:"text"` + OnClick onClick `json:"onClick"` +} + +type onClick struct { + OpenLink openLink `json:"openLink"` +} + +type openLink struct { + URL string `json:"url"` +} + +// messageOf is the Chat message of p: its Text and, for a plan or Supply +// run, a card whose "Open" button links to p.URL under the public URL. +func (s *Service) messageOf(p notify.Payload) (message, error) { + msg := message{Text: Text(p)} + if !carded[p.Event] { + return msg, nil + } + ref, err := url.Parse(p.URL) + if err != nil || ref.Scheme != "" || ref.Host != "" || !strings.HasPrefix(p.URL, "/") { + return message{}, fmt.Errorf("payload url %q is not an app path", p.URL) + } + link := s.publicURL.ResolveReference(ref) + msg.CardsV2 = []cardV2{{ + CardID: string(p.Event), + Card: card{ + Header: cardHeader{Title: p.Title, Subtitle: p.Body}, + Sections: []cardSection{{Widgets: []cardWidget{{ButtonList: buttonList{Buttons: []button{{ + Text: "Open", + OnClick: onClick{OpenLink: openLink{URL: link.String()}}, + }}}}}}}, + }, + }} + return msg, nil +} + +// post sends msg to hook, in thread when it is not empty, and returns the +// status Google Chat answered. Its error never quotes the webhook URL. +func (s *Service) post(ctx context.Context, hook Webhook, msg message, thread string) (int, error) { + body, err := json.Marshal(msg) + if err != nil { + return 0, fmt.Errorf("chat: encode message: %w", err) + } + target, err := url.Parse(hook.String()) + if err != nil { + return 0, errors.New("chat: stored webhook URL does not parse") + } + if thread != "" { + q := target.Query() + q.Set("threadKey", thread) + q.Set("messageReplyOption", replyOption) + target.RawQuery = q.Encode() + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(body)) + if err != nil { + return 0, errors.New("chat: build request to the webhook") + } + req.Header.Set("Content-Type", "application/json; charset=UTF-8") + resp, err := s.client.Do(req) + if err != nil { + // A *url.Error quotes the request URL, the webhook; keep only its + // cause. + var ue *url.Error + if errors.As(err, &ue) { + return 0, fmt.Errorf("%w: %w", ErrUnreachable, ue.Err) + } + return 0, ErrUnreachable + } + defer resp.Body.Close() + // The status is the outcome; the body is read only so the connection + // can be reused. + io.CopyN(io.Discard, resp.Body, maxDrainBytes) + return resp.StatusCode, nil +} + +// open decrypts and validates the stored webhook. Its error never quotes +// the URL. +func (s *Service) open(row db.ChatSetting) (Webhook, error) { + raw, err := s.box.Decrypt(row.WebhookUrlCt) + if err != nil { + return Webhook{}, fmt.Errorf("chat: open stored webhook: %w", err) + } + hook, err := ParseWebhook(string(raw)) + if err != nil { + return Webhook{}, fmt.Errorf("chat: stored webhook URL is invalid: %w", err) + } + return hook, nil +} + +func (s *Service) timestamp() string { + return store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String() +} + +func settingsFrom(row db.ChatSetting, hook Webhook) Settings { + masked, updated := hook.Masked(), row.UpdatedAt + return Settings{ + Configured: true, + SpaceLabel: nullString(row.SpaceLabel), + URLMasked: &masked, + LastTestAt: nullString(row.LastTestAt), + LastTestStatus: nullString(row.LastTestStatus), + UpdatedAt: &updated, + } +} + +func nullString(s sql.NullString) *string { + if !s.Valid { + return nil + } + return &s.String +} diff --git a/internal/chat/chat_test.go b/internal/chat/chat_test.go new file mode 100644 index 0000000..ce75072 --- /dev/null +++ b/internal/chat/chat_test.go @@ -0,0 +1,530 @@ +package chat + +import ( + "bytes" + "context" + "crypto/rand" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +// posted is one request the fake Chat endpoint received. +type posted struct { + Method, Host, Path string + Query url.Values + ContentType string + Body map[string]any +} + +// fakeChat is an httptest Google Chat endpoint answering status. +type fakeChat struct { + srv *httptest.Server + mu sync.Mutex + // status is what it answers. + status int + got []posted +} + +func newFakeChat(t *testing.T) *fakeChat { + t.Helper() + f := &fakeChat{status: http.StatusOK} + f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data, _ := io.ReadAll(r.Body) + var body map[string]any + if err := json.Unmarshal(data, &body); err != nil { + t.Errorf("Chat endpoint body %q: %v", data, err) + } + f.mu.Lock() + f.got = append(f.got, posted{r.Method, r.Host, r.URL.Path, r.URL.Query(), r.Header.Get("Content-Type"), body}) + status := f.status + f.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + w.Write([]byte(`{}`)) + })) + t.Cleanup(f.srv.Close) + return f +} + +func (f *fakeChat) setStatus(status int) { + f.mu.Lock() + defer f.mu.Unlock() + f.status = status +} + +func (f *fakeChat) requests() []posted { + f.mu.Lock() + defer f.mu.Unlock() + return append([]posted(nil), f.got...) +} + +// redirectTo sends every request to the server at target, keeping its path +// and query, as if it were chat.googleapis.com. +type redirectTo struct { + target *url.URL +} + +func (r redirectTo) RoundTrip(req *http.Request) (*http.Response, error) { + out := req.Clone(req.Context()) + out.URL.Scheme, out.URL.Host = r.target.Scheme, r.target.Host + out.Host = req.URL.Host + return http.DefaultTransport.RoundTrip(out) +} + +func newTestStore(t *testing.T) *store.Store { + t.Helper() + ctx := context.Background() + st, err := store.Open(ctx, t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := st.Close(); err != nil { + t.Error(err) + } + }) + if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + return st +} + +func newTestBox(t *testing.T) *secretbox.Box { + t.Helper() + key := make([]byte, secretbox.KeySize) + rand.Read(key) + box, err := secretbox.New(base64.StdEncoding.EncodeToString(key)) + if err != nil { + t.Fatal(err) + } + return box +} + +// newTestService returns a service over a migrated temp database whose +// requests go to f, with cards linking under https://mealquest.example. +func newTestService(t *testing.T, f *fakeChat) (*Service, *store.Store) { + t.Helper() + st := newTestStore(t) + target, err := url.Parse(f.srv.URL) + if err != nil { + t.Fatal(err) + } + pub, err := url.Parse("https://mealquest.example") + if err != nil { + t.Fatal(err) + } + svc, err := NewService(st, newTestBox(t), pub, redirectTo{target}) + if err != nil { + t.Fatal(err) + } + return svc, st +} + +// assertNoSecret fails when s holds the webhook URL, its key or its token. +func assertNoSecret(t *testing.T, what, s string) { + t.Helper() + tokenDecoded, _ := url.QueryUnescape(testToken) + for _, secret := range []string{testHook, testKey, testToken, tokenDecoded, testKey[:12], testToken[:12]} { + if strings.Contains(s, secret) { + t.Errorf("%s holds the webhook URL or a credential: %s", what, s) + return + } + } +} + +func TestNewServiceRequiresDependencies(t *testing.T) { + t.Parallel() + st, box := newTestStore(t), newTestBox(t) + pub, _ := url.Parse("https://mealquest.example") + for name, build := range map[string]func() (*Service, error){ + "store": func() (*Service, error) { return NewService(nil, box, pub, http.DefaultTransport) }, + "box": func() (*Service, error) { return NewService(st, nil, pub, http.DefaultTransport) }, + "public URL": func() (*Service, error) { return NewService(st, box, nil, http.DefaultTransport) }, + "transport": func() (*Service, error) { return NewService(st, box, pub, nil) }, + } { + if _, err := build(); err == nil { + t.Errorf("NewService without %s succeeded", name) + } + } +} + +func TestPutGetStoresSealedWebhook(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc, st := newTestService(t, newFakeChat(t)) + + got, err := svc.Get(ctx) + if err != nil || got.Configured || got.SpaceLabel != nil || got.URLMasked != nil || got.LastTestAt != nil { + t.Fatalf("Get unconfigured = %+v, %v", got, err) + } + + put, err := svc.Put(ctx, testHook, " Family dinner ") + if err != nil { + t.Fatal(err) + } + if !put.Configured || *put.SpaceLabel != "Family dinner" || *put.URLMasked != "https://chat.googleapis.com/v1/spaces/"+testSpace+"/messages?key=…&token=…ZxQ=" || put.LastTestAt != nil || put.LastTestStatus != nil { + t.Errorf("Put = %+v", put) + } + got, err = svc.Get(ctx) + if err != nil || *got.URLMasked != *put.URLMasked || *got.SpaceLabel != "Family dinner" { + t.Errorf("Get = %+v, %v", got, err) + } + + row, err := db.New(st.Reader).GetChatSettings(ctx) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(row.WebhookUrlCt, []byte(testKey)) || bytes.Contains(row.WebhookUrlCt, []byte("chat.googleapis.com")) { + t.Errorf("webhook_url_ct holds the URL in plain text") + } + plain, err := svc.box.Decrypt(row.WebhookUrlCt) + if err != nil || string(plain) != testHook { + t.Errorf("webhook_url_ct decrypts to %q, %v", plain, err) + } + encoded, _ := json.Marshal(got) + assertNoSecret(t, "GET settings", string(encoded)) +} + +func TestPutRefusesInvalid(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc, st := newTestService(t, newFakeChat(t)) + for _, tc := range []struct { + url, label string + fields []string + }{ + {"https://chat.googleapis.com.evil.example/v1/spaces/X/messages?key=" + testKey + "&token=" + testToken, "Family", []string{"url"}}, + {testHook, " ", []string{"space_label"}}, + {testHook, strings.Repeat("x", MaxSpaceLabelChars+1), []string{"space_label"}}, + {testHook, "bad\nlabel", []string{"space_label"}}, + {"http://chat.googleapis.com/v1/spaces/X/messages?key=" + testKey + "&token=" + testToken, "", []string{"url", "space_label"}}, + } { + _, err := svc.Put(ctx, tc.url, tc.label) + var invalid *ValidationError + if !errors.As(err, &invalid) || len(invalid.Problems) != len(tc.fields) { + t.Errorf("Put(%q, %q) = %v, want problems with %v", tc.url, tc.label, err, tc.fields) + continue + } + for i, p := range invalid.Problems { + if p.Field != tc.fields[i] { + t.Errorf("Put(%q, %q) problem %d = %s, want %s", tc.url, tc.label, i, p.Field, tc.fields[i]) + } + } + assertNoSecret(t, "validation error", err.Error()) + } + if _, err := db.New(st.Reader).GetChatSettings(ctx); !errors.Is(err, sql.ErrNoRows) { + t.Errorf("a refused Put stored a row: %v", err) + } +} + +func TestTestRecordsOutcome(t *testing.T) { + t.Parallel() + ctx := context.Background() + f := newFakeChat(t) + svc, _ := newTestService(t, f) + svc.now = func() time.Time { return time.Date(2026, 10, 9, 15, 0, 0, 0, time.UTC) } + + if _, err := svc.Test(ctx); !errors.Is(err, ErrNotConfigured) { + t.Fatalf("Test unconfigured = %v, want ErrNotConfigured", err) + } + if _, err := svc.Put(ctx, testHook, "Family"); err != nil { + t.Fatal(err) + } + + res, err := svc.Test(ctx) + if err != nil { + t.Fatal(err) + } + if res.HTTPStatus != 200 || *res.LastTestStatus != TestOK || *res.LastTestAt != "2026-10-09T15:00:00.000Z" { + t.Errorf("Test = %+v, want 200 ok", res) + } + reqs := f.requests() + if len(reqs) != 1 { + t.Fatalf("Chat got %d requests, want 1", len(reqs)) + } + r := reqs[0] + if r.Method != http.MethodPost || r.Host != "chat.googleapis.com" || r.Path != "/v1/spaces/"+testSpace+"/messages" || + r.Query.Get("key") != testKey || r.Query.Get("threadKey") != "" || r.Body["text"] != TestText || r.Body["cardsV2"] != nil { + t.Errorf("test request = %+v", r) + } + + f.setStatus(http.StatusNotFound) + res, err = svc.Test(ctx) + if err != nil || res.HTTPStatus != 404 || *res.LastTestStatus != TestFailed { + t.Errorf("Test answered 404 = %+v, %v; want 404 failed", res, err) + } + got, _ := svc.Get(ctx) + if *got.LastTestStatus != TestFailed || *got.LastTestAt != "2026-10-09T15:00:00.000Z" { + t.Errorf("Get after a failed test = %+v", got) + } + + // A new webhook has no test outcome. + got, err = svc.Put(ctx, testHook, "Family") + if err != nil || got.LastTestAt != nil || got.LastTestStatus != nil { + t.Errorf("Put after a test = %+v, %v; want the outcome cleared", got, err) + } + + f.srv.Close() + _, err = svc.Test(ctx) + if !errors.Is(err, ErrUnreachable) { + t.Errorf("Test against a stopped endpoint = %v, want ErrUnreachable", err) + } else { + assertNoSecret(t, "unreachable error", err.Error()) + } + if got, _ := svc.Get(ctx); got.LastTestStatus == nil || *got.LastTestStatus != TestFailed { + t.Errorf("Get after an unanswered test = %+v, want failed", got) + } +} + +func TestSendThreadsByWeek(t *testing.T) { + t.Parallel() + ctx := context.Background() + f := newFakeChat(t) + svc, _ := newTestService(t, f) + if _, err := svc.Put(ctx, testHook, "Family"); err != nil { + t.Fatal(err) + } + week := mustDate(t, "2026-10-12") + events := []notify.Event{ + PlanReady(week, []string{"Tacos", "Bolognese", "Tikka masala", "A", "B", "C", "D"}), + ListReady(week, 19, 7), + DayOfReminder(mustDate(t, "2026-10-18"), "01J9DINNER", "Tacos"), + NewSuggestions("01J9BATCH", week, []string{"Pho"}), + RotationReview("01J9DINNER", "Pho", week), + } + for _, ev := range events { + if err := svc.Send(ctx, notify.Message{ID: "n1", EventKey: ev.Key, Channel: notify.ChannelChat, Payload: payloadOf(ev), Attempt: 1}); err != nil { + t.Fatalf("Send %s: %v", ev.Type, err) + } + } + reqs := f.requests() + if len(reqs) != len(events) { + t.Fatalf("Chat got %d requests, want %d", len(reqs), len(events)) + } + for i, r := range reqs { + ev := events[i] + if r.Method != http.MethodPost || r.Path != "/v1/spaces/"+testSpace+"/messages" || !strings.HasPrefix(r.ContentType, "application/json") { + t.Errorf("%s: request = %+v", ev.Type, r) + } + if r.Query.Get("key") != testKey || r.Query.Get("token") != strings.ReplaceAll(testToken, "%3D", "=") { + t.Errorf("%s: key and token not kept: %v", ev.Type, r.Query) + } + if r.Query.Get("threadKey") != "mealquest-week-2026-W42" || r.Query.Get("messageReplyOption") != "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" { + t.Errorf("%s: threading = %v", ev.Type, r.Query) + } + if want := Text(payloadOf(ev)); r.Body["text"] != want { + t.Errorf("%s: text = %q, want %q", ev.Type, r.Body["text"], want) + } + _, hasCards := r.Body["cardsV2"] + if hasCards != (ev.Type == notify.PlanReady || ev.Type == notify.ListReady) { + t.Errorf("%s: cardsV2 present = %v", ev.Type, hasCards) + } + } + if reqs[0].Body["text"] != "This week's plan is ready: Tacos, Bolognese, Tikka masala + 4 more. Open the plan →" { + t.Errorf("plan text = %q", reqs[0].Body["text"]) + } + + // The cards' "Open" buttons link under the public URL. + for i, wantLink := range map[int]string{0: "https://mealquest.example/plan", 1: "https://mealquest.example/plan/shopping"} { + data, _ := json.Marshal(reqs[i].Body["cardsV2"]) + var cards []cardV2 + if err := json.Unmarshal(data, &cards); err != nil || len(cards) != 1 { + t.Fatalf("cardsV2 = %s, %v", data, err) + } + c := cards[0] + buttons := c.Card.Sections[0].Widgets[0].ButtonList.Buttons + if c.CardID != string(events[i].Type) || c.Card.Header.Title != events[i].Title || c.Card.Header.Subtitle != events[i].Body || + len(buttons) != 1 || buttons[0].Text != "Open" || buttons[0].OnClick.OpenLink.URL != wantLink { + t.Errorf("%s card = %s, want an Open button to %s", events[i].Type, data, wantLink) + } + } +} + +func TestSendWithoutWeekIsUnthreaded(t *testing.T) { + t.Parallel() + ctx := context.Background() + f := newFakeChat(t) + svc, _ := newTestService(t, f) + if _, err := svc.Put(ctx, testHook, "Family"); err != nil { + t.Fatal(err) + } + p := notify.Payload{Event: notify.RotationReview, Title: "Keep it in rotation?", URL: "/", Tag: "rotation_review:x"} + if err := svc.Send(ctx, notify.Message{Payload: p}); err != nil { + t.Fatal(err) + } + if q := f.requests()[0].Query; q.Has("threadKey") || q.Has("messageReplyOption") { + t.Errorf("query = %v, want no threading", q) + } +} + +func TestSendClassifiesStatus(t *testing.T) { + t.Parallel() + ctx := context.Background() + f := newFakeChat(t) + svc, _ := newTestService(t, f) + if _, err := svc.Put(ctx, testHook, "Family"); err != nil { + t.Fatal(err) + } + m := notify.Message{Payload: payloadOf(PlanReady(mustDate(t, "2026-10-12"), []string{"Tacos"}))} + for _, tc := range []struct { + status int + ok, permanent bool + }{ + {200, true, false}, + {204, true, false}, + {301, false, true}, + {400, false, true}, + {401, false, true}, + {403, false, true}, + {404, false, true}, + {413, false, true}, + {429, false, false}, + {500, false, false}, + {502, false, false}, + {503, false, false}, + } { + f.setStatus(tc.status) + err := svc.Send(ctx, m) + if (err == nil) != tc.ok || errors.Is(err, notify.ErrUndeliverable) != tc.permanent { + t.Errorf("status %d: Send = %v, want ok %v, undeliverable %v", tc.status, err, tc.ok, tc.permanent) + } + if err != nil { + assertNoSecret(t, "Send error", err.Error()) + } + } + + f.srv.Close() + err := svc.Send(ctx, m) + if err == nil || errors.Is(err, notify.ErrUndeliverable) { + t.Errorf("Send to a stopped endpoint = %v, want a retryable error", err) + } else { + assertNoSecret(t, "Send error", err.Error()) + } +} + +func TestSendWithoutWebhookIsUndeliverable(t *testing.T) { + t.Parallel() + svc, _ := newTestService(t, newFakeChat(t)) + err := svc.Send(context.Background(), notify.Message{Payload: payloadOf(ListReady(mustDate(t, "2026-10-12"), 3, 2))}) + if !errors.Is(err, notify.ErrUndeliverable) || !errors.Is(err, ErrNotConfigured) { + t.Errorf("Send = %v, want undeliverable, not configured", err) + } +} + +// TestOutboxRowsFollowClassification delivers through the notify worker: a +// 400 fails the row at once, a 503 leaves it pending for a retry. Neither +// the stored error nor the log holds the webhook URL. +func TestOutboxRowsFollowClassification(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + f := newFakeChat(t) + svc, st := newTestService(t, f) + if _, err := svc.Put(ctx, testHook, "Family"); err != nil { + t.Fatal(err) + } + if err := household.SeedNotificationPrefs(ctx, st); err != nil { + t.Fatal(err) + } + if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 0, chat = 1`); err != nil { + t.Fatal(err) + } + var logs syncBuffer + notifier, err := notify.NewService(st, slog.New(slog.NewTextHandler(&logs, nil))) + if err != nil { + t.Fatal(err) + } + if err := notifier.Register(notify.ChannelChat, svc); err != nil { + t.Fatal(err) + } + if err := notifier.Start(ctx); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + cancel() + notifier.Wait() + }) + + week := mustDate(t, "2026-10-12") + f.setStatus(http.StatusBadRequest) + planned := PlanReady(week, []string{"Tacos"}) + if err := notifier.Emit(ctx, planned); err != nil { + t.Fatal(err) + } + status, lastErr := waitForAttempt(t, st, planned.Key) + if status != "failed" || !strings.Contains(lastErr, "status 400") { + t.Errorf("row after a 400 = %s %q, want failed", status, lastErr) + } + + f.setStatus(http.StatusServiceUnavailable) + listed := ListReady(week, 3, 1) + if err := notifier.Emit(ctx, listed); err != nil { + t.Fatal(err) + } + status, lastErr = waitForAttempt(t, st, listed.Key) + if status != "pending" || !strings.Contains(lastErr, "status 503") { + t.Errorf("row after a 503 = %s %q, want pending for a retry", status, lastErr) + } + cancel() + notifier.Wait() + assertNoSecret(t, "log", logs.String()) + assertNoSecret(t, "last_error", lastErr) +} + +// waitForAttempt waits for the chat row of key to have been attempted and +// returns its status and last error. +func waitForAttempt(t *testing.T, st *store.Store, key string) (string, string) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + var status string + var attempts int + var lastErr sql.NullString + err := st.Reader.QueryRow(`SELECT status, attempts, last_error FROM notification_outbox WHERE event_key = ? AND channel = 'chat'`, key). + Scan(&status, &attempts, &lastErr) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + t.Fatal(err) + } + if attempts > 0 { + return status, lastErr.String + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("chat row of %s was never attempted", key) + return "", "" +} + +// syncBuffer is a bytes.Buffer safe for the worker's logger. +type syncBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *syncBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *syncBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} diff --git a/internal/chat/messages.go b/internal/chat/messages.go new file mode 100644 index 0000000..592a70d --- /dev/null +++ b/internal/chat/messages.go @@ -0,0 +1,144 @@ +package chat + +import ( + "fmt" + "strings" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// This file is the one place the notification texts are written. The +// builders make the notify.Event of each event type, whose title and body +// the push channel shows as they are; Text turns its payload into the Google +// Chat message, such as "This week's plan is ready: Tacos, Bolognese, +// Tikka masala + 4 more. Open the plan →". + +// TestText is the message POST /settings/chat-webhook/test sends. +const TestText = "MealQuest · test message" + +// listedNames is how many names a message lists before "+ N more". +const listedNames = 3 + +// actions is the closing call to action of each event's Chat message. +var actions = map[notify.EventType]string{ + notify.PlanReady: "Open the plan", + notify.ListReady: "Open the Supply run", + notify.DayOfReminder: "Open the recipe", + notify.NewSuggestions: "Open Discover", + notify.RotationReview: "Open MealQuest", +} + +// carded are the events whose Chat message also has a card with an "Open" +// button. +var carded = map[notify.EventType]bool{notify.PlanReady: true, notify.ListReady: true} + +// PlanReady is the plan_ready event of the week starting on week, naming +// its dinners in plan order. +func PlanReady(week store.LocalDate, dinners []string) notify.Event { + return notify.Event{ + Type: notify.PlanReady, + Key: string(notify.PlanReady) + ":" + week.String(), + Title: "This week's plan is ready", + Body: nameList(dinners), + URL: "/plan", + Week: week, + } +} + +// ListReady is the list_ready event of the Supply run of the week starting +// on week: needed items to buy for its dinners. +func ListReady(week store.LocalDate, needed, dinners int) notify.Event { + return notify.Event{ + Type: notify.ListReady, + Key: string(notify.ListReady) + ":" + week.String(), + Title: "This week's Supply run is ready", + Body: fmt.Sprintf("%s to buy for %s", count(needed, "item", "items"), count(dinners, "dinner", "dinners")), + URL: "/plan/shopping", + Week: week, + } +} + +// DayOfReminder is the day_of_reminder event of the dinner dinnerID, named +// name, planned on date. +func DayOfReminder(date store.LocalDate, dinnerID, name string) notify.Event { + return notify.Event{ + Type: notify.DayOfReminder, + Key: string(notify.DayOfReminder) + ":" + date.String(), + Title: "Tonight's dinner", + Body: name, + URL: "/dinner/" + dinnerID, + Week: household.WeekStart(date), + } +} + +// NewSuggestions is the new_suggestions event of suggestion batch batchID, +// made in the week starting on week, naming its Side quests. +func NewSuggestions(batchID string, week store.LocalDate, names []string) notify.Event { + return notify.Event{ + Type: notify.NewSuggestions, + Key: string(notify.NewSuggestions) + ":" + batchID, + Title: "New Side quests are in", + Body: nameList(names), + URL: "/discover", + Week: week, + } +} + +// RotationReview is the rotation_review event asking whether the dinner +// dinnerID, named name and tried in the week starting on week, becomes a +// Regular. +func RotationReview(dinnerID, name string, week store.LocalDate) notify.Event { + return notify.Event{ + Type: notify.RotationReview, + Key: string(notify.RotationReview) + ":" + dinnerID, + Title: "Keep it in rotation?", + Body: "You tried " + name + ". Make it a Regular?", + URL: "/", + Week: week, + } +} + +// Text is the Google Chat message of p: its title, its body after a colon +// and the event's call to action with an arrow. +func Text(p notify.Payload) string { + text := p.Title + if p.Body != "" { + if endsSentence(text) { + text += " " + } else { + text += ": " + } + text += p.Body + } + if !endsSentence(text) { + text += "." + } + if action, ok := actions[p.Event]; ok { + text += " " + action + " →" + } + return text +} + +// endsSentence reports whether s ends with sentence punctuation. +func endsSentence(s string) bool { + return strings.HasSuffix(s, ".") || strings.HasSuffix(s, "?") || strings.HasSuffix(s, "!") +} + +// nameList lists the first listedNames of names, then how many more there +// are: "Tacos, Bolognese, Tikka masala + 4 more". +func nameList(names []string) string { + if len(names) <= listedNames { + return strings.Join(names, ", ") + } + return fmt.Sprintf("%s + %d more", strings.Join(names[:listedNames], ", "), len(names)-listedNames) +} + +// count is n with the singular or plural noun. +func count(n int, one, many string) string { + if n == 1 { + return "1 " + one + } + return fmt.Sprintf("%d %s", n, many) +} diff --git a/internal/chat/messages_test.go b/internal/chat/messages_test.go new file mode 100644 index 0000000..4386606 --- /dev/null +++ b/internal/chat/messages_test.go @@ -0,0 +1,137 @@ +package chat + +import ( + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +func mustDate(t *testing.T, s string) store.LocalDate { + t.Helper() + d, err := store.ParseLocalDate(s) + if err != nil { + t.Fatal(err) + } + return d +} + +// payloadOf is the payload notify stores for ev. +func payloadOf(ev notify.Event) notify.Payload { + p := notify.Payload{Event: ev.Type, Title: ev.Title, Body: ev.Body, URL: ev.URL, Tag: ev.Key} + if !ev.Week.IsZero() { + p.Week = ev.Week.String() + } + return p +} + +func TestMessages(t *testing.T) { + t.Parallel() + week := mustDate(t, "2026-10-12") + for _, tc := range []struct { + name string + ev notify.Event + key, url, week string + title, body, chatT string + }{ + { + name: "plan ready", + ev: PlanReady(week, []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo", "Bibimbap", "Shakshuka", "Chili"}), + key: "plan_ready:2026-10-12", + url: "/plan", + week: "2026-10-12", + title: "This week's plan is ready", + body: "Tacos, Bolognese, Tikka masala + 4 more", + chatT: "This week's plan is ready: Tacos, Bolognese, Tikka masala + 4 more. Open the plan →", + }, + { + name: "plan ready with three dinners", + ev: PlanReady(week, []string{"Tacos", "Bolognese", "Tikka masala"}), + key: "plan_ready:2026-10-12", + url: "/plan", + week: "2026-10-12", + title: "This week's plan is ready", + body: "Tacos, Bolognese, Tikka masala", + chatT: "This week's plan is ready: Tacos, Bolognese, Tikka masala. Open the plan →", + }, + { + name: "list ready", + ev: ListReady(week, 19, 7), + key: "list_ready:2026-10-12", + url: "/plan/shopping", + week: "2026-10-12", + title: "This week's Supply run is ready", + body: "19 items to buy for 7 dinners", + chatT: "This week's Supply run is ready: 19 items to buy for 7 dinners. Open the Supply run →", + }, + { + name: "list ready singular", + ev: ListReady(week, 1, 1), + key: "list_ready:2026-10-12", + url: "/plan/shopping", + week: "2026-10-12", + title: "This week's Supply run is ready", + body: "1 item to buy for 1 dinner", + chatT: "This week's Supply run is ready: 1 item to buy for 1 dinner. Open the Supply run →", + }, + { + name: "day-of reminder", + ev: DayOfReminder(mustDate(t, "2026-10-15"), "01J9Z8Y7X6W5V4T3S2R1Q0P9N8", "Tacos"), + key: "day_of_reminder:2026-10-15", + url: "/dinner/01J9Z8Y7X6W5V4T3S2R1Q0P9N8", + week: "2026-10-12", + title: "Tonight's dinner", + body: "Tacos", + chatT: "Tonight's dinner: Tacos. Open the recipe →", + }, + { + name: "new suggestions", + ev: NewSuggestions("01J9BATCH", week, []string{"Lemon orzo", "Bibimbap", "Shakshuka", "Pho", "Paella"}), + key: "new_suggestions:01J9BATCH", + url: "/discover", + week: "2026-10-12", + title: "New Side quests are in", + body: "Lemon orzo, Bibimbap, Shakshuka + 2 more", + chatT: "New Side quests are in: Lemon orzo, Bibimbap, Shakshuka + 2 more. Open Discover →", + }, + { + name: "rotation review", + ev: RotationReview("01J9DINNER", "Lemon orzo", week), + key: "rotation_review:01J9DINNER", + url: "/", + week: "2026-10-12", + title: "Keep it in rotation?", + body: "You tried Lemon orzo. Make it a Regular?", + chatT: "Keep it in rotation? You tried Lemon orzo. Make it a Regular? Open MealQuest →", + }, + } { + ev := tc.ev + if ev.Key != tc.key || ev.URL != tc.url || ev.Week.String() != tc.week || ev.Title != tc.title || ev.Body != tc.body { + t.Errorf("%s: event = %+v, want key %q url %q week %s title %q body %q", tc.name, ev, tc.key, tc.url, tc.week, tc.title, tc.body) + } + if got := Text(payloadOf(ev)); got != tc.chatT { + t.Errorf("%s: Text = %q, want %q", tc.name, got, tc.chatT) + } + } +} + +func TestTextWithoutBody(t *testing.T) { + t.Parallel() + got := Text(notify.Payload{Event: notify.PlanReady, Title: "This week's plan is ready", URL: "/plan"}) + if want := "This week's plan is ready. Open the plan →"; got != want { + t.Errorf("Text = %q, want %q", got, want) + } +} + +func TestThreadKey(t *testing.T) { + t.Parallel() + for date, want := range map[string]string{ + "2026-10-12": "mealquest-week-2026-W42", + "2026-12-28": "mealquest-week-2026-W53", + "2027-01-04": "mealquest-week-2027-W01", + } { + if got := ThreadKey(mustDate(t, date)); got != want { + t.Errorf("ThreadKey(%s) = %q, want %q", date, got, want) + } + } +} diff --git a/internal/chat/webhook.go b/internal/chat/webhook.go new file mode 100644 index 0000000..f42ae91 --- /dev/null +++ b/internal/chat/webhook.go @@ -0,0 +1,104 @@ +package chat + +import ( + "errors" + "fmt" + "net/url" + "regexp" + "strings" +) + +const ( + // webhookPrefix is how every accepted webhook URL starts, compared + // exactly: no other scheme, host spelling, port or user info. + webhookPrefix = "https://chat.googleapis.com/v1/spaces/" + // webhookHost is the only host a webhook URL may name. + webhookHost = "chat.googleapis.com" + // MaxWebhookURLLength bounds an accepted webhook URL. + MaxWebhookURLLength = 2048 + // MinCredentialLength is the shortest key or token accepted, so the + // masked URL's token tail never gives away most of it. + MinCredentialLength = 16 + // maskedTail is how many trailing characters of the token the masked + // URL shows. + maskedTail = 4 +) + +// webhookPath is the path of a space's incoming webhook; the group is the +// space id. +var webhookPath = regexp.MustCompile(`^/v1/spaces/([A-Za-z0-9_-]{1,128})/messages$`) + +// Webhook is a validated Google Chat incoming webhook URL. Its String is +// the full URL, a secret: it is only ever sealed for storage or sent to +// Google Chat, never logged, shown or put in an error. +type Webhook struct { + raw string + space string + token string +} + +// ParseWebhook validates raw as +// https://chat.googleapis.com/v1/spaces/{space}/messages?key=…&token=…, +// with exactly the query parameters key and token, each once and at least +// MinCredentialLength characters. Its error never quotes raw. +func ParseWebhook(raw string) (Webhook, error) { + switch { + case raw == "": + return Webhook{}, errors.New("is required") + case len(raw) > MaxWebhookURLLength: + return Webhook{}, fmt.Errorf("must be at most %d characters", MaxWebhookURLLength) + case strings.ContainsFunc(raw, func(r rune) bool { return r <= ' ' || r == 0x7f }): + return Webhook{}, errors.New("must not contain spaces or control characters") + case !strings.HasPrefix(raw, webhookPrefix): + return Webhook{}, errors.New("must be a Google Chat incoming webhook URL starting with " + webhookPrefix) + case strings.Contains(raw, "#"): + return Webhook{}, errors.New("must not have a fragment") + } + u, err := url.Parse(raw) + if err != nil { + return Webhook{}, errors.New("is not a valid URL") + } + if u.Scheme != "https" || u.Host != webhookHost || u.User != nil || u.Opaque != "" || u.RawPath != "" { + return Webhook{}, errors.New("must be a Google Chat incoming webhook URL starting with " + webhookPrefix) + } + m := webhookPath.FindStringSubmatch(u.Path) + if m == nil { + return Webhook{}, errors.New("path must be /v1/spaces/{space}/messages") + } + q, err := url.ParseQuery(u.RawQuery) + if err != nil { + return Webhook{}, errors.New("query is not valid") + } + for name, values := range q { + if name != "key" && name != "token" { + return Webhook{}, errors.New("query must hold only key and token") + } + if len(values) != 1 { + return Webhook{}, fmt.Errorf("query must hold %s once", name) + } + } + for _, name := range []string{"key", "token"} { + v := q.Get(name) + if v == "" { + return Webhook{}, fmt.Errorf("query must hold a %s", name) + } + if len(v) < MinCredentialLength { + return Webhook{}, fmt.Errorf("%s must be at least %d characters", name, MinCredentialLength) + } + } + return Webhook{raw: raw, space: m[1], token: q.Get("token")}, nil +} + +// String returns the full webhook URL, a secret. +func (w Webhook) String() string { + return w.raw +} + +// Masked returns the URL with its key hidden and only the last few +// characters of its token, such as +// "https://chat.googleapis.com/v1/spaces/AAQA1234/messages?key=…&token=…x3Dk". +func (w Webhook) Masked() string { + // MinCredentialLength bytes are at least maskedTail runes. + token := []rune(w.token) + return webhookPrefix + w.space + "/messages?key=…&token=…" + string(token[len(token)-maskedTail:]) +} diff --git a/internal/chat/webhook_test.go b/internal/chat/webhook_test.go new file mode 100644 index 0000000..c5c4e96 --- /dev/null +++ b/internal/chat/webhook_test.go @@ -0,0 +1,94 @@ +package chat + +import ( + "strings" + "testing" +) + +const ( + testKey = "AIzaSyDdI0hCZtE6vySjMm-WEfRq3CPzqKqqsHI" + testToken = "q0Lz3kV9Yw8pXcRtN2bHsJdFgA7uEmKiOlP1nZxQ%3D" + testSpace = "AAQA1b2C3d4" + testHook = "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey + "&token=" + testToken +) + +func TestParseWebhookAccepts(t *testing.T) { + t.Parallel() + for _, raw := range []string{ + testHook, + "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?token=" + testToken + "&key=" + testKey, + "https://chat.googleapis.com/v1/spaces/AAAA_x-y/messages?key=" + testKey + "&token=" + testToken, + } { + hook, err := ParseWebhook(raw) + if err != nil { + t.Errorf("ParseWebhook(%q) = %v, want accepted", raw, err) + continue + } + if hook.String() != raw { + t.Errorf("String() = %q, want %q", hook.String(), raw) + } + } +} + +func TestParseWebhookRejects(t *testing.T) { + t.Parallel() + q := "?key=" + testKey + "&token=" + testToken + for name, raw := range map[string]string{ + "empty": "", + "http": "http://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q, + "upper-case scheme": "HTTPS://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q, + "lookalike suffix": "https://chat.googleapis.com.evil.example/v1/spaces/" + testSpace + "/messages" + q, + "lookalike prefix": "https://evilchat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q, + "other google host": "https://www.googleapis.com/v1/spaces/" + testSpace + "/messages" + q, + "upper-case host": "https://CHAT.googleapis.com/v1/spaces/" + testSpace + "/messages" + q, + "port": "https://chat.googleapis.com:443/v1/spaces/" + testSpace + "/messages" + q, + "user info": "https://user@chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q, + "trailing dot host": "https://chat.googleapis.com./v1/spaces/" + testSpace + "/messages" + q, + "no space": "https://chat.googleapis.com/v1/spaces//messages" + q, + "extra path": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages/x" + q, + "other version": "https://chat.googleapis.com/v2/spaces/" + testSpace + "/messages" + q, + "members path": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/members" + q, + "escaped path": "https://chat.googleapis.com/v1/spaces/AA%2FQA/messages" + q, + "dot segment": "https://chat.googleapis.com/v1/spaces/../messages" + q, + "no query": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages", + "no key": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?token=" + testToken, + "no token": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey, + "empty token": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey + "&token=", + "short key": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=abc&token=" + testToken, + "repeated key": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "&key=" + testKey, + "extra parameter": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "&threadKey=x", + "bad escape in query": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "%zz", + "fragment": testHook + "#frag", + "space": testHook + " ", + "newline": testHook + "\n", + "too long": testHook + strings.Repeat("A", MaxWebhookURLLength), + "not a URL": "chat webhook please", + } { + _, err := ParseWebhook(raw) + if err == nil { + t.Errorf("%s: ParseWebhook(%q) accepted", name, raw) + continue + } + for _, secret := range []string{testKey, testToken, testSpace} { + if strings.Contains(err.Error(), secret) { + t.Errorf("%s: error %q quotes the URL", name, err) + } + } + } +} + +func TestWebhookMasked(t *testing.T) { + t.Parallel() + hook, err := ParseWebhook(testHook) + if err != nil { + t.Fatal(err) + } + // The tail is of the token as it decodes: "%3D" is "=". + want := "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=…&token=…ZxQ=" + if got := hook.Masked(); got != want { + t.Errorf("Masked() = %q, want %q", got, want) + } + if m := hook.Masked(); strings.Contains(m, testKey) || strings.Contains(m, testToken) || strings.Contains(m, testToken[:8]) { + t.Errorf("Masked() = %q holds the key or token", m) + } +} diff --git a/internal/db/querier.go b/internal/db/querier.go index 1a1f6cd..8d3e14c 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -260,6 +260,10 @@ type Querier interface { // event id while its dinner stays the same; a new dinner clears it, since // Calendar no longer shows the day as planned. PutPlanDay(ctx context.Context, arg PutPlanDayParams) error + // Records the outcome of a test message sent to the webhook sealed in + // webhook_url_ct; affects no row when the webhook has been replaced or + // removed since the test began. + RecordChatTest(ctx context.Context, arg RecordChatTestParams) (int64, error) // Counts a delivery to a subscription that the push service did not accept. RecordPushFailure(ctx context.Context, id string) error // Records that the push service accepted a notification for a subscription. diff --git a/internal/db/settings.sql.go b/internal/db/settings.sql.go index a68719b..04e9ec7 100644 --- a/internal/db/settings.sql.go +++ b/internal/db/settings.sql.go @@ -161,6 +161,35 @@ func (q *Queries) GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSett return i, err } +const recordChatTest = `-- name: RecordChatTest :execrows +UPDATE chat_settings +SET last_test_at = ?, last_test_status = ?, updated_at = ? +WHERE id = 1 AND webhook_url_ct = ? +` + +type RecordChatTestParams struct { + LastTestAt sql.NullString + LastTestStatus sql.NullString + UpdatedAt string + WebhookUrlCt []byte +} + +// Records the outcome of a test message sent to the webhook sealed in +// webhook_url_ct; affects no row when the webhook has been replaced or +// removed since the test began. +func (q *Queries) RecordChatTest(ctx context.Context, arg RecordChatTestParams) (int64, error) { + result, err := q.db.ExecContext(ctx, recordChatTest, + arg.LastTestAt, + arg.LastTestStatus, + arg.UpdatedAt, + arg.WebhookUrlCt, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const setIntegrationsGoogleCalendar = `-- name: SetIntegrationsGoogleCalendar :execrows UPDATE integrations_google SET calendar_id = ?, calendar_name = ?, updated_at = ? diff --git a/internal/notify/notify.go b/internal/notify/notify.go index 92a5906..31017b5 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -12,7 +12,8 @@ // registered for their channel. A delivered row is marked sent. A failed // delivery is retried after a backoff of one minute doubling up to one hour, // until 24 hours after the row was created; the delivery that fails at or -// after that point marks it failed, with the error in last_error. +// after that point, or with an error wrapping ErrUndeliverable, marks it +// failed, with the error in last_error. package notify import ( diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go index 16b3470..cb124e5 100644 --- a/internal/notify/notify_test.go +++ b/internal/notify/notify_test.go @@ -422,6 +422,31 @@ func TestRetryThenSent(t *testing.T) { } } +func TestUndeliverableFailsAtOnce(t *testing.T) { + ctx := context.Background() + svc, st, _ := newTestService(t) + setPref(t, st, PlanReady, false, true) + sender := &fakeSender{fail: fmt.Errorf("%w: chat refused the message (status 400)", ErrUndeliverable)} + if err := svc.Register(ChannelChat, sender); err != nil { + t.Fatal(err) + } + const key = "plan_ready:2026-10-12" + if err := svc.Emit(ctx, planReady(key)); err != nil { + t.Fatal(err) + } + wait, err := svc.deliverDue(ctx) + if err != nil { + t.Fatal(err) + } + r := onlyRow(t, st, key, ChannelChat) + if r.Status != "failed" || r.Attempts != 1 || r.LastError.String != sender.fail.Error() { + t.Fatalf("row = %+v, want failed after one attempt with the sender's error", r) + } + if wait != -1 || len(sender.messages()) != 1 { + t.Fatalf("wait = %v after %d deliveries, want nothing pending after one", wait, len(sender.messages())) + } +} + func TestGiveUpAfter24Hours(t *testing.T) { ctx := context.Background() svc, st, c := newTestService(t) diff --git a/internal/notify/worker.go b/internal/notify/worker.go index 796e7c2..c188556 100644 --- a/internal/notify/worker.go +++ b/internal/notify/worker.go @@ -48,8 +48,14 @@ type Message struct { Attempt int64 } +// ErrUndeliverable is wrapped by a Sender's error when retrying cannot +// help, such as a channel refusing the message outright: the worker marks +// the row failed at once instead of retrying it. +var ErrUndeliverable = errors.New("notify: undeliverable") + // Sender delivers notifications on one channel. Send returns nil once the -// channel has accepted m, and an error otherwise; the worker retries it. +// channel has accepted m, and an error otherwise; the worker retries it, +// unless the error wraps ErrUndeliverable. // The error's text is stored in notification_outbox.last_error and logged, // so it must never carry a secret: no webhook URL, key or token (a // *url.Error quotes its URL). @@ -235,7 +241,7 @@ func (s *Service) deliver(ctx context.Context, row db.NotificationOutbox) { return } - if !now.Before(deadline) { + if errors.Is(err, ErrUndeliverable) || !now.Before(deadline) { logger.ErrorContext(ctx, "notification failed; giving up", "attempt", attempt, "error", err) s.giveUp(ctx, logger, row, 1, err.Error()) return diff --git a/internal/server/aisettings_test.go b/internal/server/aisettings_test.go index 18a3a2a..9dcd118 100644 --- a/internal/server/aisettings_test.go +++ b/internal/server/aisettings_test.go @@ -28,7 +28,7 @@ const ( func newAIRouter(t *testing.T) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), + r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/chat.go b/internal/server/chat.go new file mode 100644 index 0000000..75a735d --- /dev/null +++ b/internal/server/chat.go @@ -0,0 +1,84 @@ +package server + +import ( + "errors" + "log/slog" + "net/http" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat" +) + +// chatWebhookBody is the PUT /settings/chat-webhook body. Both fields are +// required. +type chatWebhookBody struct { + URL *string `json:"url"` + SpaceLabel *string `json:"space_label"` +} + +// getChatWebhook answers the stored webhook's settings, which never hold +// its URL. +func getChatWebhook(logger *slog.Logger, svc *chat.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + settings, err := svc.Get(r.Context()) + if err != nil { + logger.ErrorContext(r.Context(), "get chat webhook", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + writeJSON(w, r, logger, http.StatusOK, settings) + } +} + +// putChatWebhook stores the webhook URL sealed, with its space label, and +// answers the settings as GET does. +func putChatWebhook(logger *slog.Logger, svc *chat.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body, err := decodePatch[chatWebhookBody](http.MaxBytesReader(w, r.Body, maxSettingsBody)) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + if body.URL == nil || body.SpaceLabel == nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "url and space_label are required") + return + } + settings, err := svc.Put(r.Context(), *body.URL, *body.SpaceLabel) + var invalid *chat.ValidationError + if errors.As(err, &invalid) { + writeError(w, r, logger, http.StatusBadRequest, "invalid_webhook", invalid.Error()) + return + } + if err != nil { + logger.ErrorContext(r.Context(), "save chat webhook", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + writeJSON(w, r, logger, http.StatusOK, settings) + } +} + +// postChatWebhookTest sends chat.TestText to the stored webhook and answers +// 200 with the status Google Chat answered, whatever it was, and the +// recorded outcome. No webhook stored, or one replaced during the test, is +// 409; no answer from Google Chat is 502. +func postChatWebhookTest(logger *slog.Logger, svc *chat.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + result, err := svc.Test(r.Context()) + switch { + case errors.Is(err, chat.ErrNotConfigured): + writeError(w, r, logger, http.StatusConflict, "not_configured", "no Google Chat webhook is configured") + case errors.Is(err, chat.ErrWebhookChanged): + writeError(w, r, logger, http.StatusConflict, "webhook_changed", "the Google Chat webhook changed during the test; test it again") + case err != nil && r.Context().Err() != nil: + logger.WarnContext(r.Context(), "chat webhook test interrupted", "error", err) + case errors.Is(err, chat.ErrUnreachable): + logger.WarnContext(r.Context(), "chat webhook test failed", "error", err) + writeError(w, r, logger, http.StatusBadGateway, "chat_unreachable", "Google Chat did not answer the test message") + case err != nil: + logger.ErrorContext(r.Context(), "chat webhook test", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + default: + writeJSON(w, r, logger, http.StatusOK, result) + } + } +} diff --git a/internal/server/chat_test.go b/internal/server/chat_test.go new file mode 100644 index 0000000..14ca2c6 --- /dev/null +++ b/internal/server/chat_test.go @@ -0,0 +1,239 @@ +package server + +import ( + "bytes" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "testing/fstest" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat" +) + +const ( + chatWebhookPath = "/api/v1/settings/chat-webhook" + chatWebhookTestPath = "/api/v1/settings/chat-webhook/test" + testChatKey = "AIzaSyDdI0hCZtE6vySjMm-WEfRq3CPzqKqqsHI" + testChatToken = "q0Lz3kV9Yw8pXcRtN2bHsJdFgA7uEmKiOlP1nZxQ%3D" + testChatHook = "https://chat.googleapis.com/v1/spaces/AAQA1b2C3d4/messages?key=" + testChatKey + "&token=" + testChatToken +) + +// refuseTransport fails every request, as a Chat endpoint that is down. +type refuseTransport struct{} + +func (refuseTransport) RoundTrip(*http.Request) (*http.Response, error) { + return nil, errors.New("connection refused") +} + +// chatTransport sends every request to the server at target, as if it +// were chat.googleapis.com. +type chatTransport struct { + target *url.URL +} + +func (c chatTransport) RoundTrip(req *http.Request) (*http.Response, error) { + out := req.Clone(req.Context()) + out.URL.Scheme, out.URL.Host = c.target.Scheme, c.target.Host + return http.DefaultTransport.RoundTrip(out) +} + +// newTestChat returns a Google Chat service over a migrated temp database +// whose requests all fail. +func newTestChat(t *testing.T) *chat.Service { + t.Helper() + return newTestChatVia(t, refuseTransport{}) +} + +func newTestChatVia(t *testing.T, transport http.RoundTripper) *chat.Service { + t.Helper() + pub, err := url.Parse("https://mealquest.example") + if err != nil { + t.Fatal(err) + } + svc, err := chat.NewService(newTestStore(t), newTestBox(t), pub, transport) + if err != nil { + t.Fatal(err) + } + return svc +} + +// lockedBuffer is a bytes.Buffer safe for a logger. +type lockedBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *lockedBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *lockedBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +// newChatRouter returns a router serving svc, logging at debug level to +// logs. +func newChatRouter(t *testing.T, svc *chat.Service, logs io.Writer) http.Handler { + t.Helper() + logger := slog.New(slog.NewTextHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), svc, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), + newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + if err != nil { + t.Fatal(err) + } + return r +} + +func TestChatWebhookAPI(t *testing.T) { + t.Parallel() + var ( + mu sync.Mutex + status = http.StatusOK + received []string + ) + chatSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + mu.Lock() + defer mu.Unlock() + received = append(received, string(body)) + w.WriteHeader(status) + })) + t.Cleanup(chatSrv.Close) + target, _ := url.Parse(chatSrv.URL) + setStatus := func(s int) { + mu.Lock() + defer mu.Unlock() + status = s + } + + var logs lockedBuffer + router := newChatRouter(t, newTestChatVia(t, chatTransport{target}), &logs) + var bodies []string + call := func(method, target, body string) (*httptest.ResponseRecorder, map[string]any) { + t.Helper() + var r io.Reader + if body != "" { + r = strings.NewReader(body) + } + rec, decoded := serve(t, router, signedIn(method, target, r)) + bodies = append(bodies, rec.Body.String()) + return rec, decoded + } + + rec, body := call(http.MethodGet, chatWebhookPath, "") + if rec.Code != http.StatusOK || body["configured"] != false || body["url_masked"] != nil || body["space_label"] != nil { + t.Errorf("GET unconfigured = %d %v", rec.Code, body) + } + rec, body = call(http.MethodPost, chatWebhookTestPath, "") + if rec.Code != http.StatusConflict || body["error"].(map[string]any)["code"] != "not_configured" { + t.Errorf("POST test unconfigured = %d %v", rec.Code, body) + } + + q := "?key=" + testChatKey + "&token=" + testChatToken + for _, bad := range []struct{ body, code string }{ + {`{"url":"https://chat.googleapis.com.evil.example/v1/spaces/X/messages` + q + `","space_label":"Family"}`, "invalid_webhook"}, + {`{"url":"http://chat.googleapis.com/v1/spaces/X/messages` + q + `","space_label":"Family"}`, "invalid_webhook"}, + {`{"url":"https://chat.googleapis.com/v1/spaces/X/messages?key=` + testChatKey + `","space_label":"Family"}`, "invalid_webhook"}, + {`{"url":"https://chat.googleapis.com/v1/spaces/X/threads` + q + `","space_label":"Family"}`, "invalid_webhook"}, + {`{"url":"` + testChatHook + `","space_label":""}`, "invalid_webhook"}, + {`{"url":"` + testChatHook + `"}`, "invalid_request"}, + {`{"space_label":"Family"}`, "invalid_request"}, + {`{"url":"` + testChatHook + `","space_label":null}`, "invalid_request"}, + {`{"url":"` + testChatHook + `","space_label":"Family","extra":1}`, "invalid_request"}, + {`not json`, "invalid_request"}, + } { + rec, body := call(http.MethodPut, chatWebhookPath, bad.body) + if rec.Code != http.StatusBadRequest || body["error"].(map[string]any)["code"] != bad.code { + t.Errorf("PUT %s = %d %v, want 400 %s", bad.body, rec.Code, body, bad.code) + } + } + + rec, body = call(http.MethodPut, chatWebhookPath, `{"url":"`+testChatHook+`","space_label":"Family dinner"}`) + wantMasked := "https://chat.googleapis.com/v1/spaces/AAQA1b2C3d4/messages?key=…&token=…ZxQ=" + if rec.Code != http.StatusOK || body["configured"] != true || body["space_label"] != "Family dinner" || body["url_masked"] != wantMasked || + body["last_test_at"] != nil || body["last_test_status"] != nil { + t.Errorf("PUT = %d %v", rec.Code, body) + } + if _, ok := body["url"]; ok { + t.Errorf("PUT response has a url field: %v", body) + } + rec, body = call(http.MethodGet, chatWebhookPath, "") + if rec.Code != http.StatusOK || body["space_label"] != "Family dinner" || body["url_masked"] != wantMasked { + t.Errorf("GET = %d %v", rec.Code, body) + } + + rec, body = call(http.MethodPost, chatWebhookTestPath, "") + if rec.Code != http.StatusOK || body["http_status"] != float64(200) || body["last_test_status"] != "ok" || body["last_test_at"] == nil { + t.Errorf("POST test = %d %v, want 200 ok", rec.Code, body) + } + mu.Lock() + if len(received) != 1 || !strings.Contains(received[0], `"text":"MealQuest · test message"`) { + t.Errorf("Chat received %q, want the test message", received) + } + mu.Unlock() + + setStatus(http.StatusForbidden) + rec, body = call(http.MethodPost, chatWebhookTestPath, "") + if rec.Code != http.StatusOK || body["http_status"] != float64(403) || body["last_test_status"] != "failed" { + t.Errorf("POST test answered 403 = %d %v, want 403 failed", rec.Code, body) + } + rec, body = call(http.MethodGet, chatWebhookPath, "") + if body["last_test_status"] != "failed" || body["last_test_at"] == nil { + t.Errorf("GET after a failed test = %d %v", rec.Code, body) + } + + chatSrv.Close() + rec, body = call(http.MethodPost, chatWebhookTestPath, "") + if rec.Code != http.StatusBadGateway || body["error"].(map[string]any)["code"] != "chat_unreachable" { + t.Errorf("POST test against a stopped endpoint = %d %v, want 502 chat_unreachable", rec.Code, body) + } + + tokenDecoded, _ := url.QueryUnescape(testChatToken) + for what, texts := range map[string][]string{"response": bodies, "log": {logs.String()}} { + for _, text := range texts { + for _, secret := range []string{testChatHook, testChatKey, testChatToken, tokenDecoded, testChatKey[:12], testChatToken[:12]} { + if strings.Contains(text, secret) { + t.Errorf("%s holds the webhook URL or a credential: %s", what, text) + } + } + } + } + if !strings.Contains(logs.String(), "chat webhook test failed") { + t.Errorf("log %q does not record the failed test, so the scan proves nothing", logs.String()) + } +} + +func TestChatWebhookNeedsCSRF(t *testing.T) { + t.Parallel() + router := newChatRouter(t, newTestChat(t), io.Discard) + for _, tc := range []struct{ method, target string }{ + {http.MethodPut, chatWebhookPath}, + {http.MethodPost, chatWebhookTestPath}, + } { + req := httptest.NewRequest(tc.method, tc.target, strings.NewReader(`{"url":"`+testChatHook+`","space_label":"Family"}`)) + req.AddCookie(validSessionCookie) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Errorf("%s %s without CSRF token = %d, want 403", tc.method, tc.target, rec.Code) + } + } +} + +func TestNewRouterRequiresChat(t *testing.T) { + t.Parallel() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + t.Fatal("NewRouter accepted a nil Google Chat service") + } +} diff --git a/internal/server/dinners_test.go b/internal/server/dinners_test.go index eb61c2f..3e363b9 100644 --- a/internal/server/dinners_test.go +++ b/internal/server/dinners_test.go @@ -91,7 +91,7 @@ func newDinnersEnv(t *testing.T) *dinnersEnv { if err != nil { t.Fatal(err) } - e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), + e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/google_test.go b/internal/server/google_test.go index 5a87fd4..7d89bd0 100644 --- a/internal/server/google_test.go +++ b/internal/server/google_test.go @@ -68,7 +68,7 @@ func newGoogleEnv(t *testing.T) *googleEnv { t.Fatal(err) } logger := slog.New(slog.NewJSONHandler(e.logs, &slog.HandlerOptions{Level: slog.LevelDebug})) - e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), + e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { diff --git a/internal/server/login_test.go b/internal/server/login_test.go index 8d326b4..e4d416a 100644 --- a/internal/server/login_test.go +++ b/internal/server/login_test.go @@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler { t.Fatal(err) } r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t), - newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/media_test.go b/internal/server/media_test.go index eda9ff0..d0246e0 100644 --- a/internal/server/media_test.go +++ b/internal/server/media_test.go @@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) { t.Fatal(err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), + r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/origin_test.go b/internal/server/origin_test.go index eac10b2..5325501 100644 --- a/internal/server/origin_test.go +++ b/internal/server/origin_test.go @@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler { } logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil))) r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t), - newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/plans_test.go b/internal/server/plans_test.go index 88053e9..8d79900 100644 --- a/internal/server/plans_test.go +++ b/internal/server/plans_test.go @@ -93,7 +93,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv { if err != nil { t.Fatal(err) } - h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), + h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/push_test.go b/internal/server/push_test.go index 961e199..25e38a7 100644 --- a/internal/server/push_test.go +++ b/internal/server/push_test.go @@ -52,7 +52,7 @@ func newTestPushWith(t *testing.T) (*push.Service, *store.Store, vapid.KeyPair) func newPushRouter(t *testing.T, svc *push.Service) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), svc, newTestRunner(t), + r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), svc, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) @@ -100,7 +100,7 @@ func TestNewRouterRequiresPush(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil push service") } } diff --git a/internal/server/recipesearch_test.go b/internal/server/recipesearch_test.go index 9869c1e..f912c29 100644 --- a/internal/server/recipesearch_test.go +++ b/internal/server/recipesearch_test.go @@ -22,7 +22,7 @@ const ( func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), + r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/server.go b/internal/server/server.go index e0f4125..9413989 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -11,6 +11,7 @@ import ( "github.com/go-chi/chi/v5" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend" @@ -40,6 +41,7 @@ type RouteMounter interface { // /media; settings serves /api/v1/settings/household; search serves // /api/v1/settings/recipe-search and its /test; ai serves // /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt; +// chatSvc serves GET and PUT /api/v1/settings/chat-webhook and its /test; // dinnerSvc serves GET and POST /api/v1/dinners, PATCH and DELETE // /api/v1/dinners/{id}, POST /api/v1/dinners/{id}/favorite, GET // /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground; @@ -63,7 +65,7 @@ type RouteMounter interface { // GET /media/photos/{id}-{size}.jpg; assets is the web build // served for every other GET. /health, /auth and the web build are public. // Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader. -func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, pushSvc *push.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) { +func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, chatSvc *chat.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, pushSvc *push.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) { if settings == nil { return nil, errors.New("server: nil household settings service") } @@ -73,6 +75,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a if ai == nil { return nil, errors.New("server: nil AI settings service") } + if chatSvc == nil { + return nil, errors.New("server: nil Google Chat service") + } if dinnerSvc == nil { return nil, errors.New("server: nil dinners service") } @@ -134,6 +139,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a apiV1.Post("/settings/ai/verify", postAIVerify(logger, ai)) apiV1.Get("/settings/ai/models", getAIModels(logger, ai)) apiV1.Post("/settings/ai/reset-prompt", postAIResetPrompt(logger, ai)) + apiV1.Get("/settings/chat-webhook", getChatWebhook(logger, chatSvc)) + apiV1.Put("/settings/chat-webhook", putChatWebhook(logger, chatSvc)) + apiV1.Post("/settings/chat-webhook/test", postChatWebhookTest(logger, chatSvc)) apiV1.Get("/dinners", getDinners(logger, dinnerSvc)) apiV1.Post("/dinners", postDinner(logger, dinnerSvc)) apiV1.Patch("/dinners/{id}", patchDinner(logger, dinnerSvc)) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 70fee30..acf75cb 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -54,7 +54,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting "index.html": {Data: []byte(fixtureIndex)}, "assets/index-AbC123.js": {Data: []byte(fixtureAsset)}, } - r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), assets) + r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), assets) if err != nil { t.Fatal(err) } @@ -287,7 +287,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil AI settings service") } } @@ -296,7 +296,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil recipe search settings service") } } @@ -305,7 +305,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted nil photo files") } } @@ -314,7 +314,7 @@ func TestNewRouterRequiresSettings(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil settings service") } } @@ -323,7 +323,7 @@ func TestNewRouterRequiresDinners(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil dinners service") } } @@ -332,7 +332,7 @@ func TestNewRouterRequiresSuggestions(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil suggestions service") } } @@ -341,7 +341,7 @@ func TestNewRouterRequiresPlan(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil plan service") } } @@ -350,7 +350,7 @@ func TestNewRouterRequiresShopping(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil shopping service") } } @@ -359,7 +359,7 @@ func TestNewRouterRequiresGoogle(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil Google Calendar service") } } @@ -368,7 +368,7 @@ func TestNewRouterRequiresJobRunner(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil job runner") } } diff --git a/internal/server/settings_test.go b/internal/server/settings_test.go index 0498264..58bba10 100644 --- a/internal/server/settings_test.go +++ b/internal/server/settings_test.go @@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) { } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st), - newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/suggestions_test.go b/internal/server/suggestions_test.go index 78106d0..e1b8938 100644 --- a/internal/server/suggestions_test.go +++ b/internal/server/suggestions_test.go @@ -44,7 +44,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S if err != nil { t.Fatal(err) } - h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), + h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/migrations/queries/settings.sql b/migrations/queries/settings.sql index d9983ec..f40a0d7 100644 --- a/migrations/queries/settings.sql +++ b/migrations/queries/settings.sql @@ -146,3 +146,11 @@ ON CONFLICT (id) DO UPDATE SET last_test_status = excluded.last_test_status, updated_at = excluded.updated_at RETURNING *; + +-- name: RecordChatTest :execrows +-- Records the outcome of a test message sent to the webhook sealed in +-- webhook_url_ct; affects no row when the webhook has been replaced or +-- removed since the test began. +UPDATE chat_settings +SET last_test_at = ?, last_test_status = ?, updated_at = ? +WHERE id = 1 AND webhook_url_ct = ?;