home.Service aggregates the Home screen from the services that own each value, at "now" in the household's zone: the date label, tonight's dinner from the current week's plan (null with no plan), the pending Side quest count from the deck, the Supply run status of the upcoming week or else the current one, and recentUpdates.unread. unread is whether a notification was stored in notification_outbox after household_settings.updates_seen_at (or at all while it is null), read through the new notify.StoredSince over the HasNotificationSince query. Nothing writes updates_seen_at yet; the updates task adds that. Tests under America/Chicago show tonight's dinner flips at local midnight, not UTC midnight, and cover no plan, no suggestions, the Supply run choice and unread. Maestro task 9ccfc3a6-cc7b-493a-aa4b-2b455d26f4df. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
240 lines
8.9 KiB
Go
240 lines
8.9 KiB
Go
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), newTestIdeas(t), newTestHome(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["url_masked"] != nil || body["space_label"] != nil || body["last_test_at"] != nil || body["last_test_status"] != nil || len(body) != 4 {
|
|
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["space_label"] != "Family dinner" || body["url_masked"] != wantMasked ||
|
|
body["last_test_at"] != nil || body["last_test_status"] != nil || len(body) != 4 {
|
|
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), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
|
t.Fatal("NewRouter accepted a nil Google Chat service")
|
|
}
|
|
}
|