package formserver import ( "context" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/api/handler" "github.com/warmbly/warmbly/internal/app/form" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" ) // stubFormService backs the REAL internal handlers, so this file tests the // whole wire: forms service -> HTTP -> backend handler -> service call. type stubFormService struct { form.Service // unimplemented methods panic; the internal endpoints use only these mu sync.Mutex form *models.Form answers map[string][]string meta form.SubmitMeta events []form.EventInput reject *errx.Error // linkToken maps to linkContact when ResolveLink is asked about it. linkToken string linkContact uuid.UUID prefill map[string]string } func (s *stubFormService) PublicForm(_ context.Context, publicID string) (*models.Form, *errx.Error) { if s.form == nil || publicID != s.form.PublicID { return nil, errx.New(errx.NotFound, "form not found") } return s.form, nil } func (s *stubFormService) RecordEvent(_ context.Context, publicID string, in form.EventInput) *errx.Error { if s.form == nil || publicID != s.form.PublicID { return errx.New(errx.NotFound, "form not found") } s.mu.Lock() s.events = append(s.events, in) s.mu.Unlock() return nil } func (s *stubFormService) ResolveLink(_ context.Context, f *models.Form, token string) (*models.FormLink, map[string]string) { if token == "" || token != s.linkToken || f == nil || f.ID != s.form.ID { return nil, nil } return &models.FormLink{ID: uuid.MustParse(token), FormID: f.ID, ContactID: s.linkContact}, s.prefill } func (s *stubFormService) Submit(_ context.Context, publicID string, answers map[string][]string, meta form.SubmitMeta) (*form.SubmitResult, *errx.Error) { if s.form == nil || publicID != s.form.PublicID { return nil, errx.New(errx.NotFound, "form not found") } if s.reject != nil { return nil, s.reject } s.mu.Lock() s.answers, s.meta = answers, meta s.mu.Unlock() return &form.SubmitResult{Message: "Thanks!"}, nil } func (s *stubFormService) eventCount(t string) int { s.mu.Lock() defer s.mu.Unlock() n := 0 for _, e := range s.events { if e.Type == t { n++ } } return n } const shellMarker = `
` func writeStaticDir(t *testing.T) string { t.Helper() dir := t.TempDir() html := `` + shellMarker + `` if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(html), 0o644); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Join(dir, "assets"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "assets", "index-abc123.js"), []byte("console.log(1)"), 0o644); err != nil { t.Fatal(err) } return dir } func newFixture(t *testing.T, submitLimit int) (*stubFormService, *Server, *gin.Engine) { t.Helper() gin.SetMode(gin.TestMode) stub := &stubFormService{form: &models.Form{ ID: uuid.New(), PublicID: "pubtest123", Name: "Demo request", Fields: []models.FormField{ {ID: "email", Type: models.FormFieldEmail, Label: "Work email", Required: true, MapTo: "email"}, }, AllowedDomains: []string{"example.com"}, }} h := &handler.Handler{FormService: stub} backend := gin.New() backend.GET("/api/v1/internal/forms/:publicID", h.InternalGetPublicForm) backend.POST("/api/v1/internal/forms/:publicID/events", h.InternalRecordFormEvent) backend.POST("/api/v1/internal/forms/:publicID/submissions", h.InternalSubmitForm) ts := httptest.NewServer(backend) t.Cleanup(ts.Close) srv, err := New(Config{BackendURL: ts.URL, InternalToken: "test-token", StaticDir: writeStaticDir(t), SubmitLimit: submitLimit}) if err != nil { t.Fatalf("new server: %v", err) } r, err := srv.Router(nil) if err != nil { t.Fatalf("router: %v", err) } return stub, srv, r } func get(r *gin.Engine, path, renderToken string) *httptest.ResponseRecorder { w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, path, nil) if renderToken != "" { req.Header.Set("X-Warmbly-Render", renderToken) } r.ServeHTTP(w, req) return w } func post(r *gin.Engine, path, renderToken string, body any) *httptest.ResponseRecorder { raw, _ := json.Marshal(body) req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(string(raw))) req.Header.Set("Content-Type", "application/json") if renderToken != "" { req.Header.Set("X-Warmbly-Render", renderToken) } w := httptest.NewRecorder() r.ServeHTTP(w, req) return w } func mintFor(srv *Server, publicID string) string { return mintRenderToken(srv.renderKey, publicID, time.Now()) } func TestFormServerServesShellWithRenderToken(t *testing.T) { _, _, r := newFixture(t, 0) w := get(r, "/f/pubtest123", "") if w.Code != http.StatusOK { t.Fatalf("shell status %d: %s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), shellMarker) { t.Fatal("shell did not serve the built app") } if strings.Contains(w.Body.String(), `name="wf-token" content=""`) { t.Fatal("render token was not stamped into the shell") } if !strings.Contains(w.Body.String(), `name="wf-token" content="`) { t.Fatal("shell is missing the render token meta") } if csp := w.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "example.com") { t.Fatalf("embed allowlist not enforced: %q", csp) } if w := get(r, "/f/unknownid", ""); w.Code != http.StatusNotFound { t.Fatalf("unknown id status %d", w.Code) } } func TestFormServerRequiresRenderToken(t *testing.T) { _, srv, r := newFixture(t, 0) if w := get(r, "/api/forms/pubtest123", ""); w.Code != http.StatusForbidden { t.Fatalf("missing token status %d, want 403", w.Code) } if w := get(r, "/api/forms/pubtest123", "garbage"); w.Code != http.StatusForbidden { t.Fatalf("forged token status %d, want 403", w.Code) } // A token minted for another form must not open this one. if w := get(r, "/api/forms/pubtest123", mintFor(srv, "otherform")); w.Code != http.StatusForbidden { t.Fatalf("cross-form token status %d, want 403", w.Code) } expired := mintRenderToken(srv.renderKey, "pubtest123", time.Now().Add(-2*renderTokenTTL)) if w := get(r, "/api/forms/pubtest123", expired); w.Code != http.StatusForbidden { t.Fatalf("expired token status %d, want 403", w.Code) } var body struct { Error string `json:"error"` } w := get(r, "/api/forms/pubtest123", "garbage") if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil || body.Error != "stale_page" { t.Fatalf("403 body: %s", w.Body.String()) } if w := get(r, "/api/forms/pubtest123", mintFor(srv, "pubtest123")); w.Code != http.StatusOK { t.Fatalf("valid token status %d: %s", w.Code, w.Body.String()) } } func TestFormServerServesFormJSONAndAssets(t *testing.T) { _, srv, r := newFixture(t, 0) token := mintFor(srv, "pubtest123") w := get(r, "/api/forms/pubtest123", token) if w.Code != http.StatusOK { t.Fatalf("form json status %d: %s", w.Code, w.Body.String()) } var res struct { PublicID string `json:"public_id"` Name string `json:"name"` Fields []models.FormField `json:"fields"` Allowed []string `json:"allowed_domains"` } if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { t.Fatalf("decode: %v", err) } if res.Name != "Demo request" || len(res.Fields) != 1 || res.Fields[0].Label != "Work email" { t.Fatalf("form json: %s", w.Body.String()) } if res.Allowed != nil { t.Fatal("embed allowlist must not reach the client") } if w := get(r, "/api/forms/unknownid", mintFor(srv, "unknownid")); w.Code != http.StatusNotFound { t.Fatalf("unknown id status %d", w.Code) } a := get(r, "/assets/index-abc123.js", "") if a.Code != http.StatusOK || !strings.Contains(a.Header().Get("Cache-Control"), "immutable") { t.Fatalf("asset serving: %d %q", a.Code, a.Header().Get("Cache-Control")) } } func TestFormServerPrefillPassthrough(t *testing.T) { stub, srv, r := newFixture(t, 0) stub.linkToken = uuid.NewString() stub.linkContact = uuid.New() stub.prefill = map[string]string{"email": "jane@example.com"} w := get(r, "/api/forms/pubtest123?t="+stub.linkToken, mintFor(srv, "pubtest123")) if w.Code != http.StatusOK { t.Fatalf("status %d: %s", w.Code, w.Body.String()) } var res struct { Prefill map[string]string `json:"prefill"` LinkToken string `json:"link_token"` } if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { t.Fatalf("decode: %v", err) } if res.Prefill["email"] != "jane@example.com" || res.LinkToken != stub.linkToken { t.Fatalf("prefill did not cross the wire: %s", w.Body.String()) } // A bogus token degrades to the anonymous payload, never an error. w = get(r, "/api/forms/pubtest123?t="+uuid.NewString(), mintFor(srv, "pubtest123")) if w.Code != http.StatusOK { t.Fatalf("bogus token status %d", w.Code) } if strings.Contains(w.Body.String(), "jane@example.com") { t.Fatal("prefill leaked to an unidentified visitor") } } func TestFormServerRecordsEventsWithViewDedupe(t *testing.T) { stub, srv, r := newFixture(t, 0) token := mintFor(srv, "pubtest123") view := map[string]any{"type": "view", "visitor_key": "vk-1", "pages_total": 2, "source_url": "https://example.com/pricing"} if w := post(r, "/api/forms/pubtest123/events", token, view); w.Code != http.StatusNoContent { t.Fatalf("event status %d", w.Code) } // Same visitor again: deduped before it reaches the backend. post(r, "/api/forms/pubtest123/events", token, view) // Different visitor: counts. post(r, "/api/forms/pubtest123/events", token, map[string]any{"type": "view", "visitor_key": "vk-2"}) // Page progress is never deduped. post(r, "/api/forms/pubtest123/events", token, map[string]any{"type": "page", "visitor_key": "vk-1", "page_index": 1, "pages_total": 2}) deadline := time.Now().Add(2 * time.Second) for (stub.eventCount("view") < 2 || stub.eventCount("page") < 1) && time.Now().Before(deadline) { time.Sleep(10 * time.Millisecond) } time.Sleep(50 * time.Millisecond) if got := stub.eventCount("view"); got != 2 { t.Fatalf("view events forwarded = %d, want 2", got) } if got := stub.eventCount("page"); got != 1 { t.Fatalf("page events forwarded = %d, want 1", got) } // Forwarding is async, so find the view rather than assuming arrival order. stub.mu.Lock() var view1 *form.EventInput for i, e := range stub.events { if e.Type == "view" && e.VisitorKey == "vk-1" { view1 = &stub.events[i] break } } stub.mu.Unlock() if view1 == nil { t.Fatal("the first view never reached the backend") } if view1.SourceURL != "https://example.com/pricing" || view1.RemoteIP == "" { t.Fatalf("event enrichment inputs lost: %+v", *view1) } // Prefetches are dropped before the budget or the backend. w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/forms/pubtest123/events", strings.NewReader(`{"type":"view","visitor_key":"vk-3"}`)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Warmbly-Render", token) req.Header.Set("Sec-Purpose", "prefetch") r.ServeHTTP(w, req) time.Sleep(50 * time.Millisecond) if got := stub.eventCount("view"); got != 2 { t.Fatalf("prefetch view was forwarded (count %d)", got) } } func TestFormServerForwardsSubmission(t *testing.T) { stub, srv, r := newFixture(t, 0) stub.linkToken = uuid.NewString() stub.linkContact = uuid.New() w := post(r, "/api/forms/pubtest123/submit", mintFor(srv, "pubtest123"), map[string]any{ "answers": map[string][]string{"email": {"visitor@example.com"}}, "website": "bot-filled", // honeypot: forwarded as a signal, not an answer "_wt": 1700000000, "link_token": stub.linkToken, "visitor_key": "vk-9", }) if w.Code != http.StatusOK { t.Fatalf("submit status %d: %s", w.Code, w.Body.String()) } var res struct { Message string `json:"message"` } if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil || res.Message != "Thanks!" { t.Fatalf("submit response: %s", w.Body.String()) } if got := stub.answers["email"]; len(got) != 1 || got[0] != "visitor@example.com" { t.Fatalf("answers did not cross the wire: %+v", stub.answers) } if !stub.meta.HoneypotFilled { t.Fatal("honeypot signal lost on the wire") } if stub.meta.RenderedAt.Unix() != 1700000000 { t.Fatalf("rendered-at lost on the wire: %v", stub.meta.RenderedAt) } if stub.meta.LinkToken != stub.linkToken || stub.meta.VisitorKey != "vk-9" { t.Fatalf("attribution lost on the wire: %+v", stub.meta) } } func TestFormServerRelaysRejectionMessage(t *testing.T) { stub, srv, r := newFixture(t, 0) stub.reject = errx.New(errx.BadRequest, "Email is required.") w := post(r, "/api/forms/pubtest123/submit", mintFor(srv, "pubtest123"), map[string]any{ "answers": map[string][]string{"email": {""}}, }) if w.Code != http.StatusBadRequest { t.Fatalf("status %d", w.Code) } var res struct { Message string `json:"message"` } if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil || res.Message != "Email is required." { t.Fatalf("rejection not relayed verbatim: %s", w.Body.String()) } } func TestFormServerSubmitRateLimit(t *testing.T) { _, srv, r := newFixture(t, 2) token := mintFor(srv, "pubtest123") payload := map[string]any{"answers": map[string][]string{"email": {"a@b.co"}}} for i := 0; i < 2; i++ { if w := post(r, "/api/forms/pubtest123/submit", token, payload); w.Code != http.StatusOK { t.Fatalf("submit %d status %d", i, w.Code) } } w := post(r, "/api/forms/pubtest123/submit", token, payload) if w.Code != http.StatusTooManyRequests { t.Fatalf("third submit status %d, want 429", w.Code) } }