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