package api import ( "bytes" "encoding/base64" "encoding/json" "io" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/rs/zerolog" "kereru.forms/internal/database" ) func newTestRouter(t *testing.T) (*gin.Engine, database.Store) { t.Helper() path := filepath.Join(t.TempDir(), "api-test.db") store, err := database.Open("sqlite", "file:"+path+"?_pragma=foreign_keys(1)") if err != nil { t.Fatalf("open database: %v", err) } t.Cleanup(func() { _ = store.Close() }) server := NewServer(store, zerolog.Nop(), "test-version") router := NewRouter(server, zerolog.Nop(), RouterOptions{CORSOrigins: []string{"http://localhost:5173"}}) return router, store } func doRequest(t *testing.T, router *gin.Engine, method, path string, body any) *httptest.ResponseRecorder { t.Helper() var reader io.Reader if body != nil { payload, err := json.Marshal(body) if err != nil { t.Fatalf("marshal request: %v", err) } reader = bytes.NewReader(payload) } req := httptest.NewRequest(method, path, reader) if body != nil { req.Header.Set("Content-Type", "application/json") } req.Header.Set("User-Agent", "kereru-test-agent") w := httptest.NewRecorder() router.ServeHTTP(w, req) return w } func decode[T any](t *testing.T, w *httptest.ResponseRecorder) T { t.Helper() var out T if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { t.Fatalf("decode response %q: %v", w.Body.String(), err) } return out } func boolPtr(b bool) *bool { return &b } func f32Ptr(f float32) *float32 { return &f } func strPtr(s string) *string { return &s } func createSampleForm(t *testing.T, router *gin.Engine) Form { t.Helper() options := []QuestionOption{ {Value: "red", Label: "Red"}, {Value: "blue", Label: "Blue"}, } body := CreateFormRequest{ Title: "Customer feedback", Description: strPtr("A short survey"), Introduction: strPtr("Thanks for helping us improve."), RequiresAccessCode: boolPtr(true), LogoUrl: strPtr("https://example.com/logo.png"), Questions: []QuestionInput{ {Key: "name", Label: "Your name", Type: Text, Required: true}, {Key: "happy", Label: "Are you happy?", Type: Boolean, Required: true}, {Key: "score", Label: "Score", Type: Number, MinValue: f32Ptr(1), MaxValue: f32Ptr(10)}, {Key: "colour", Label: "Favourite colour", Type: Select, Options: &options}, {Key: "when", Label: "Date", Type: Date}, }, } w := doRequest(t, router, http.MethodPost, "/forms", body) if w.Code != http.StatusCreated { t.Fatalf("create form: status %d body %s", w.Code, w.Body.String()) } return decode[Form](t, w) } func TestHealth(t *testing.T) { router, _ := newTestRouter(t) for _, path := range []string{"/health", "/api/health"} { w := doRequest(t, router, http.MethodGet, path, nil) if w.Code != http.StatusOK { t.Fatalf("health %s: status %d", path, w.Code) } health := decode[Health](t, w) if health.Status != "ok" || health.Version != "test-version" { t.Fatalf("unexpected health: %+v", health) } if health.UptimeSeconds == nil { t.Fatal("uptimeSeconds should be present") } } } func TestFormLifecycle(t *testing.T) { router, _ := newTestRouter(t) form := createSampleForm(t, router) if form.Version != 1 || !form.IsActive || len(form.Questions) != 5 { t.Fatalf("unexpected form: %+v", form) } w := doRequest(t, router, http.MethodGet, "/forms/"+form.Id.String(), nil) if w.Code != http.StatusOK { t.Fatalf("get form: %d %s", w.Code, w.Body.String()) } w = doRequest(t, router, http.MethodGet, "/forms", nil) if w.Code != http.StatusOK { t.Fatalf("list forms: %d", w.Code) } forms := decode[[]FormSummary](t, w) if len(forms) != 1 || forms[0].QuestionCount != 5 { t.Fatalf("unexpected forms: %+v", forms) } if forms[0].LogoUrl == nil || *forms[0].LogoUrl != "https://example.com/logo.png" { t.Fatalf("logo url missing from summary: %+v", forms[0]) } w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/access-codes", CreateAccessCodesRequest{Count: 2, Prefix: strPtr("demo")}) if w.Code != http.StatusCreated { t.Fatalf("create codes: %d %s", w.Code, w.Body.String()) } codes := decode[[]AccessCode](t, w) if len(codes) != 2 || !strings.HasPrefix(codes[0].Code, "DEMO-") { t.Fatalf("unexpected codes: %+v", codes) } w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/inactivate", nil) if w.Code != http.StatusOK { t.Fatalf("inactivate: %d %s", w.Code, w.Body.String()) } if inactive := decode[Form](t, w); inactive.IsActive { t.Fatal("form should be inactive") } w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/activate", nil) if w.Code != http.StatusOK { t.Fatalf("activate: %d", w.Code) } w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/versions", nil) if w.Code != http.StatusCreated { t.Fatalf("create version: %d %s", w.Code, w.Body.String()) } v2 := decode[Form](t, w) if v2.Version != 2 || v2.FormGroupId != form.FormGroupId { t.Fatalf("unexpected version: %+v", v2) } if v2.LogoUrl == nil || *v2.LogoUrl != "https://example.com/logo.png" { t.Fatalf("new version should keep the logo: %+v", v2) } w = doRequest(t, router, http.MethodGet, "/forms/"+form.Id.String(), nil) old := decode[Form](t, w) if old.IsActive { t.Fatal("previous version should be inactive") } } func TestSubmitFlow(t *testing.T) { router, _ := newTestRouter(t) form := createSampleForm(t, router) byKey := map[string]Question{} for _, q := range form.Questions { byKey[q.Key] = q } answers := []Answer{ {QuestionId: byKey["name"].Id, Value: "Ada"}, {QuestionId: byKey["happy"].Id, Value: true}, {QuestionId: byKey["score"].Id, Value: 8}, {QuestionId: byKey["colour"].Id, Value: "blue"}, {QuestionId: byKey["when"].Id, Value: "2026-10-23"}, } w := doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/access-codes", CreateAccessCodesRequest{Count: 1}) if w.Code != http.StatusCreated { t.Fatalf("create code: %d", w.Code) } code := decode[[]AccessCode](t, w)[0].Code submit := SubmitFormRequest{ UserId: "ada@example.com", UserIdType: Email, Answers: answers, Tracking: &ClientTracking{Locale: strPtr("en-NZ")}, } w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", submit) if w.Code != http.StatusBadRequest { t.Fatalf("missing access code should be 400, got %d %s", w.Code, w.Body.String()) } submit.AccessCode = &code w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", submit) if w.Code != http.StatusCreated { t.Fatalf("submit: %d %s", w.Code, w.Body.String()) } submission := decode[Submission](t, w) if submission.FormVersion != 1 || len(submission.Answers) != 5 { t.Fatalf("unexpected submission: %+v", submission) } var storedDate string for _, answer := range submission.Answers { if answer.QuestionKey == "when" { storedDate, _ = answer.Value.(string) } } if storedDate != "2026-10-23" { t.Fatalf("date answer not stored correctly: %+v", submission.Answers) } if submission.Tracking == nil || submission.Tracking.UserAgent == nil || *submission.Tracking.UserAgent != "kereru-test-agent" { t.Fatalf("server-side tracking missing: %+v", submission.Tracking) } w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", submit) if w.Code != http.StatusConflict { t.Fatalf("reused code should be 409, got %d %s", w.Code, w.Body.String()) } w = doRequest(t, router, http.MethodGet, "/forms/"+form.Id.String()+"/submissions", nil) if w.Code != http.StatusOK { t.Fatalf("list submissions: %d", w.Code) } subs := decode[[]SubmissionSummary](t, w) if len(subs) != 1 || subs[0].Id != submission.Id { t.Fatalf("unexpected submissions: %+v", subs) } w = doRequest(t, router, http.MethodGet, "/submissions/"+submission.Id.String(), nil) if w.Code != http.StatusOK { t.Fatalf("get submission: %d", w.Code) } w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/inactivate", nil) if w.Code != http.StatusOK { t.Fatalf("inactivate: %d", w.Code) } second := SubmitFormRequest{ UserId: "ada@example.com", UserIdType: Email, Answers: answers, } w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", second) if w.Code != http.StatusGone { t.Fatalf("inactive form should be 410, got %d %s", w.Code, w.Body.String()) } } func TestSubmitValidation(t *testing.T) { router, _ := newTestRouter(t) form := createSampleForm(t, router) byKey := map[string]Question{} for _, q := range form.Questions { byKey[q.Key] = q } cases := []struct { name string answers []Answer }{ { name: "missing required", answers: []Answer{{QuestionId: byKey["score"].Id, Value: 5}}, }, { name: "number out of range", answers: []Answer{ {QuestionId: byKey["name"].Id, Value: "Ada"}, {QuestionId: byKey["happy"].Id, Value: true}, {QuestionId: byKey["score"].Id, Value: 42}, }, }, { name: "bad select option", answers: []Answer{ {QuestionId: byKey["name"].Id, Value: "Ada"}, {QuestionId: byKey["happy"].Id, Value: true}, {QuestionId: byKey["colour"].Id, Value: "green"}, }, }, { name: "bad date", answers: []Answer{ {QuestionId: byKey["name"].Id, Value: "Ada"}, {QuestionId: byKey["happy"].Id, Value: true}, {QuestionId: byKey["when"].Id, Value: "23/10/2026"}, }, }, { name: "wrong type", answers: []Answer{ {QuestionId: byKey["name"].Id, Value: "Ada"}, {QuestionId: byKey["happy"].Id, Value: "yes"}, }, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { w := doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", SubmitFormRequest{UserId: "ada@example.com", UserIdType: Email, Answers: tc.answers}) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d %s", w.Code, w.Body.String()) } }) } } func TestCreateFormValidation(t *testing.T) { router, _ := newTestRouter(t) options := []QuestionOption{{Value: "a", Label: "A"}} cases := []CreateFormRequest{ {Title: "", Questions: []QuestionInput{{Key: "a", Label: "A", Type: Text}}}, {Title: "Test", Questions: []QuestionInput{{Key: "1bad", Label: "A", Type: Text}}}, {Title: "Test", Questions: []QuestionInput{{Key: "dup", Label: "A", Type: Text}, {Key: "dup", Label: "B", Type: Text}}}, {Title: "Test", Questions: []QuestionInput{{Key: "a", Label: "A", Type: "datetime"}}}, {Title: "Test", Questions: []QuestionInput{{Key: "a", Label: "A", Type: Select}}}, {Title: "Test", Questions: []QuestionInput{{Key: "a", Label: "A", Type: Number, MinValue: f32Ptr(10), MaxValue: f32Ptr(1)}}}, {Title: "Test", Questions: []QuestionInput{{Key: "a", Label: "A", Type: Select, Options: &options}, {Key: "a", Label: "B", Type: Text}}}, {Title: "Test", Questions: []QuestionInput{{Key: "a", Label: "A", Type: Text}}, LogoUrl: strPtr("javascript:alert(1)")}, {Title: "Test", Questions: []QuestionInput{{Key: "a", Label: "A", Type: Text}}, LogoUrl: strPtr("not a url")}, } for i, body := range cases { w := doRequest(t, router, http.MethodPost, "/forms", body) if w.Code != http.StatusBadRequest { t.Fatalf("case %d: expected 400, got %d %s", i, w.Code, w.Body.String()) } } } func TestPDFAndCORS(t *testing.T) { router, _ := newTestRouter(t) form := createSampleForm(t, router) w := doRequest(t, router, http.MethodGet, "/forms/"+form.Id.String()+"/pdf", nil) if w.Code != http.StatusOK { t.Fatalf("pdf: %d %s", w.Code, w.Body.String()) } if ct := w.Header().Get("Content-Type"); ct != "application/pdf" { t.Fatalf("content type = %q", ct) } if !bytes.HasPrefix(w.Body.Bytes(), []byte("%PDF")) { t.Fatal("response is not a PDF") } req := httptest.NewRequest(http.MethodOptions, "/forms", nil) req.Header.Set("Origin", "http://localhost:5173") req.Header.Set("Access-Control-Request-Method", "POST") corsW := httptest.NewRecorder() router.ServeHTTP(corsW, req) if corsW.Code != http.StatusNoContent { t.Fatalf("preflight status = %d", corsW.Code) } if got := corsW.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:5173" { t.Fatalf("allow origin = %q", got) } } func TestNotFound(t *testing.T) { router, _ := newTestRouter(t) missing := uuid.NewString() for _, tc := range []struct { method string path string }{ {http.MethodGet, "/forms/" + missing}, {http.MethodDelete, "/forms/" + missing}, {http.MethodPost, "/forms/" + missing + "/inactivate"}, {http.MethodGet, "/forms/" + missing + "/submissions"}, {http.MethodGet, "/submissions/" + missing}, {http.MethodGet, "/submissions/" + missing + "/offline-pdf"}, } { w := doRequest(t, router, tc.method, tc.path, nil) if w.Code != http.StatusNotFound { t.Fatalf("%s %s: expected 404, got %d", tc.method, tc.path, w.Code) } } } func createSignatureForm(t *testing.T, router *gin.Engine) Form { t.Helper() body := CreateFormRequest{ Title: "Signed declaration", SignatureRequired: boolPtr(true), Questions: []QuestionInput{ {Key: "name", Label: "Your name", Type: Text, Required: true}, }, } w := doRequest(t, router, http.MethodPost, "/forms", body) if w.Code != http.StatusCreated { t.Fatalf("create signature form: %d %s", w.Code, w.Body.String()) } return decode[Form](t, w) } func pngDataURL() string { data := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a} return "data:image/png;base64," + base64.StdEncoding.EncodeToString(data) } func pdfDataURL(data []byte) string { return "data:application/pdf;base64," + base64.StdEncoding.EncodeToString(data) } func TestUpdateFormCreatesVersion(t *testing.T) { router, _ := newTestRouter(t) form := createSampleForm(t, router) update := UpdateFormRequest{ Title: "Customer feedback v2", PdfUrl: strPtr("https://example.com/prebuilt.pdf"), ExternalReferenceRequired: boolPtr(true), Questions: []QuestionInput{ {Key: "name", Label: "Your name", Type: Text, Required: true}, }, } w := doRequest(t, router, http.MethodPut, "/forms/"+form.Id.String(), update) if w.Code != http.StatusOK { t.Fatalf("update form: %d %s", w.Code, w.Body.String()) } updated := decode[Form](t, w) if updated.Id == form.Id { t.Fatal("editing should create a new form id") } if updated.Version != 2 || updated.FormGroupId != form.FormGroupId { t.Fatalf("unexpected versioned form: %+v", updated) } if updated.PdfUrl == nil || *updated.PdfUrl != "https://example.com/prebuilt.pdf" { t.Fatalf("pdf url not saved: %+v", updated.PdfUrl) } if updated.SignatureRequired == nil || *updated.SignatureRequired { t.Fatalf("signature flag should preserve false: %+v", updated.SignatureRequired) } if updated.ExternalReferenceRequired == nil || !*updated.ExternalReferenceRequired || updated.ExternalReferenceLabel == nil || *updated.ExternalReferenceLabel != "External reference" { t.Fatalf("external reference was not enabled with a default label: %+v", updated) } if len(updated.Questions) != 1 || updated.Questions[0].Id.String() == "" { t.Fatalf("expected a fresh question: %+v", updated.Questions) } w = doRequest(t, router, http.MethodGet, "/forms/"+form.Id.String(), nil) if w.Code != http.StatusOK { t.Fatalf("get old form: %d", w.Code) } old := decode[Form](t, w) if old.IsActive { t.Fatal("previous version should be inactive") } w = doRequest(t, router, http.MethodGet, "/forms?includeInactive=true", nil) if w.Code != http.StatusOK { t.Fatalf("list forms: %d", w.Code) } forms := decode[[]FormSummary](t, w) if len(forms) != 2 { t.Fatalf("expected two versions, got %+v", forms) } } func TestExternalReference(t *testing.T) { router, _ := newTestRouter(t) body := CreateFormRequest{ Title: "Invoice approval", ExternalReferenceRequired: boolPtr(true), ExternalReferenceLabel: strPtr("Invoice number"), Questions: []QuestionInput{ {Key: "note", Label: "Note", Type: Text, Required: true}, }, } w := doRequest(t, router, http.MethodPost, "/forms", body) if w.Code != http.StatusCreated { t.Fatalf("create form: %d %s", w.Code, w.Body.String()) } form := decode[Form](t, w) if form.ExternalReferenceRequired == nil || !*form.ExternalReferenceRequired || form.ExternalReferenceLabel == nil || *form.ExternalReferenceLabel != "Invoice number" { t.Fatalf("unexpected external reference config: %+v", form) } submit := SubmitFormRequest{ UserId: "ada@example.com", UserIdType: Email, Answers: []Answer{{ QuestionId: form.Questions[0].Id, Value: "Approved", }}, } w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", submit) if w.Code != http.StatusBadRequest { t.Fatalf("missing external reference should be 400, got %d %s", w.Code, w.Body.String()) } submit.ExternalReference = strPtr("INV-42") w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", submit) if w.Code != http.StatusCreated { t.Fatalf("submit with reference: %d %s", w.Code, w.Body.String()) } submission := decode[Submission](t, w) if submission.ExternalReference == nil || *submission.ExternalReference != "INV-42" { t.Fatalf("external reference not returned: %+v", submission.ExternalReference) } w = doRequest(t, router, http.MethodGet, "/submissions/"+submission.Id.String(), nil) if w.Code != http.StatusOK { t.Fatalf("get submission: %d %s", w.Code, w.Body.String()) } stored := decode[Submission](t, w) if stored.ExternalReference == nil || *stored.ExternalReference != "INV-42" { t.Fatalf("external reference not persisted: %+v", stored.ExternalReference) } w = doRequest(t, router, http.MethodGet, "/forms/"+form.Id.String()+"/submissions", nil) if w.Code != http.StatusOK { t.Fatalf("list submissions: %d %s", w.Code, w.Body.String()) } summaries := decode[[]SubmissionSummary](t, w) if len(summaries) != 1 || summaries[0].ExternalReference == nil || *summaries[0].ExternalReference != "INV-42" { t.Fatalf("reference missing from summary: %+v", summaries) } } func TestSignatureSubmission(t *testing.T) { router, _ := newTestRouter(t) form := createSignatureForm(t, router) questionID := form.Questions[0].Id base := SubmitFormRequest{ UserId: "ada@example.com", UserIdType: Email, Answers: []Answer{{QuestionId: questionID, Value: "Ada"}}, } w := doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", base) if w.Code != http.StatusBadRequest { t.Fatalf("missing signature should be 400, got %d %s", w.Code, w.Body.String()) } base.Signature = &SignatureInput{ Type: Drawn, DataUrl: pngDataURL(), Name: "Ada Lovelace", Title: strPtr("Analyst"), } w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", base) if w.Code != http.StatusCreated { t.Fatalf("signed submission: %d %s", w.Code, w.Body.String()) } submission := decode[Submission](t, w) if submission.Signature == nil || submission.Signature.Name != "Ada Lovelace" || submission.Signature.Title == nil || *submission.Signature.Title != "Analyst" || submission.Signature.SignedAt.IsZero() { t.Fatalf("signature not returned: %+v", submission.Signature) } w = doRequest(t, router, http.MethodGet, "/submissions/"+submission.Id.String(), nil) if w.Code != http.StatusOK { t.Fatalf("get submission: %d %s", w.Code, w.Body.String()) } stored := decode[Submission](t, w) if stored.Signature == nil || stored.Signature.DataUrl != pngDataURL() { t.Fatalf("signature not persisted: %+v", stored.Signature) } base.Signature = &SignatureInput{Type: Drawn, DataUrl: pngDataURL(), Name: ""} w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", base) if w.Code != http.StatusBadRequest { t.Fatalf("empty signature name should be 400, got %d %s", w.Code, w.Body.String()) } } func TestOfflinePDFSubmission(t *testing.T) { router, _ := newTestRouter(t) form := createSignatureForm(t, router) pdf := []byte("%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF\n") submit := SubmitFormRequest{ UserId: "ada@example.com", UserIdType: Email, OfflinePdf: strPtr(pdfDataURL(pdf)), OfflinePdfName: strPtr("signed-declaration.pdf"), } invalid := submit invalid.OfflinePdf = strPtr(pdfDataURL([]byte("not a pdf"))) w := doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", invalid) if w.Code != http.StatusBadRequest { t.Fatalf("invalid pdf should be 400, got %d %s", w.Code, w.Body.String()) } w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", submit) if w.Code != http.StatusCreated { t.Fatalf("offline submission: %d %s", w.Code, w.Body.String()) } submission := decode[Submission](t, w) if len(submission.Answers) != 0 || submission.OfflinePdfName == nil || *submission.OfflinePdfName != "signed-declaration.pdf" || submission.Signature != nil { t.Fatalf("unexpected offline submission: %+v", submission) } w = doRequest(t, router, http.MethodGet, "/submissions/"+submission.Id.String()+"/offline-pdf", nil) if w.Code != http.StatusOK { t.Fatalf("download offline pdf: %d %s", w.Code, w.Body.String()) } if got := w.Header().Get("Content-Type"); got != "application/pdf" { t.Fatalf("content type = %q", got) } if got := w.Header().Get("Content-Disposition"); !strings.Contains(got, "signed-declaration.pdf") { t.Fatalf("content disposition = %q", got) } if !bytes.Equal(w.Body.Bytes(), pdf) { t.Fatalf("downloaded pdf differs from upload") } }