Add internal/plan: a draft_week job that drafts the week through the
harness from every Regular and the Quest log dinners no other current or
later plan holds, given the household context and last week's dinners,
and stores it as the week's plan; plan reads with the week label, is_new
and each crock-pot dinner's start_by (dinner time minus minutes_total,
computed on read); and day swaps that recompute is_new and move a synced
plan to changed. Drafting over a synced or changed plan needs confirm, at
enqueue and again when the job stores the week; a redraft keeps each
day's calendar event id.
Serve GET /api/v1/plans/{weekStart}, POST /api/v1/plans/{weekStart}/draft
(202 {job_id}, single-flight per week) and PUT
/api/v1/plans/{weekStart}/days/{date}; a weekStart that is not a Monday
is 400 invalid_week.
Maestro task ea48e650-d510-49cc-8485-0f07174ee731.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
146 lines
4.7 KiB
Go
146 lines
4.7 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
|
)
|
|
|
|
// traefik is the production proxy's address.
|
|
const traefik = "10.60.1.33"
|
|
|
|
// probeAuth mounts GET /auth/probe, which reports the request's resolved
|
|
// origin, in place of the real auth flow.
|
|
type probeAuth struct{}
|
|
|
|
func (probeAuth) Mount(r chi.Router) {
|
|
r.Get("/auth/probe", func(w http.ResponseWriter, r *http.Request) {
|
|
ip, ipOK := forwarded.ClientIP(r.Context())
|
|
scheme, schemeOK := forwarded.Scheme(r.Context())
|
|
if !ipOK || !schemeOK {
|
|
http.Error(w, "origin not resolved", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
fmt.Fprintf(w, "%s %s", ip, scheme)
|
|
})
|
|
}
|
|
|
|
// newOriginRouter builds the production router trusting Traefik, logging
|
|
// through forwarded.LogHandler as cmd/server does.
|
|
func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
|
|
t.Helper()
|
|
trusted, err := forwarded.ParseTrusted(traefik)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
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), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return r
|
|
}
|
|
|
|
func originRequest(target, peer string, headers map[string]string) *http.Request {
|
|
req := httptest.NewRequest(http.MethodGet, target, nil)
|
|
req.RemoteAddr = peer
|
|
for k, v := range headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
return req
|
|
}
|
|
|
|
func TestRouterResolvesOrigin(t *testing.T) {
|
|
t.Parallel()
|
|
spoofed := map[string]string{forwarded.HeaderFor: "203.0.113.7", forwarded.HeaderProto: "https"}
|
|
tests := []struct {
|
|
name string
|
|
peer string
|
|
want string
|
|
}{
|
|
{"trusted proxy's forwarded headers are honoured", traefik + ":41000", "203.0.113.7 https"},
|
|
{"untrusted peer spoofing them is ignored", "198.51.100.9:5000", "198.51.100.9 http"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
rec := httptest.NewRecorder()
|
|
newOriginRouter(t, io.Discard).ServeHTTP(rec, originRequest("/auth/probe", tt.peer, spoofed))
|
|
if rec.Code != http.StatusOK || rec.Body.String() != tt.want {
|
|
t.Errorf("probe = %d %q, want 200 %q", rec.Code, rec.Body.String(), tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRouterLogsResolvedClientIP(t *testing.T) {
|
|
t.Parallel()
|
|
spoofed := map[string]string{forwarded.HeaderFor: "203.0.113.7", forwarded.HeaderProto: "https"}
|
|
tests := []struct {
|
|
name string
|
|
peer string
|
|
want string
|
|
}{
|
|
{"trusted proxy", traefik + ":41000", "203.0.113.7"},
|
|
{"untrusted peer spoofing", "198.51.100.9:5000", "198.51.100.9"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
var logs bytes.Buffer
|
|
// A failed session lookup is logged with the request's context.
|
|
req := originRequest("/api/v1/me", tt.peer, spoofed)
|
|
req.AddCookie(brokenSessionCookie)
|
|
rec := httptest.NewRecorder()
|
|
newOriginRouter(t, &logs).ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Fatalf("status = %d, want 500", rec.Code)
|
|
}
|
|
var line map[string]any
|
|
if err := json.Unmarshal(logs.Bytes(), &line); err != nil {
|
|
t.Fatalf("log %q: %v", logs.String(), err)
|
|
}
|
|
if line["msg"] != "look up session" || line[forwarded.LogAttr] != tt.want {
|
|
t.Errorf("log = %v, want the session error with %s=%s", line, forwarded.LogAttr, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRouterRejectsMalformedForwardedHeaders(t *testing.T) {
|
|
t.Parallel()
|
|
const badRequestJSON = `{"error":{"code":"bad_request","message":"invalid forwarded headers","retryable":false}}`
|
|
for _, headers := range []map[string]string{
|
|
{forwarded.HeaderFor: "not-an-ip"},
|
|
{forwarded.HeaderProto: "ftp"},
|
|
} {
|
|
var logs bytes.Buffer
|
|
rec := httptest.NewRecorder()
|
|
newOriginRouter(t, &logs).ServeHTTP(rec, originRequest("/health", traefik+":41000", headers))
|
|
if rec.Code != http.StatusBadRequest || rec.Body.String() != badRequestJSON {
|
|
t.Errorf("%v: response = %d %s, want 400 %s", headers, rec.Code, rec.Body.String(), badRequestJSON)
|
|
}
|
|
if !bytes.Contains(logs.Bytes(), []byte("rejected forwarded headers")) {
|
|
t.Errorf("%v: logs = %q, want the rejection logged", headers, logs.String())
|
|
}
|
|
|
|
// The same headers from an untrusted peer are not examined at all.
|
|
rec = httptest.NewRecorder()
|
|
newOriginRouter(t, io.Discard).ServeHTTP(rec, originRequest("/health", "198.51.100.9:5000", headers))
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("%v from an untrusted peer: status = %d, want 200", headers, rec.Code)
|
|
}
|
|
}
|
|
}
|