Files
warmbly/internal/formserver/server_test.go
T

405 lines
14 KiB
Go

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 = `<div id="root"></div>`
func writeStaticDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
html := `<!doctype html><html><head><meta name="wf-token" content="" /></head><body>` + shellMarker + `</body></html>`
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)
}
}