mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-05 16:02:48 +00:00
405 lines
14 KiB
Go
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)
|
|
}
|
|
}
|