mirror of
https://github.com/cole-maxwell1/todo-hetzner.git
synced 2026-10-27 19:02:49 -05:00
Implement the server-rendered Go todo application with PostgreSQL persistence, Tailwind styling, health checks, and test coverage. Provision production and staging infrastructure with OpenTofu, including private networking, static IPs, Object Storage backups, and load balancing. Configure Rocky Linux hosts with Ansible, rootless Podman, PostgreSQL, WAL-G backup automation, staging restores, and generated inventory.
131 lines
3.8 KiB
Go
131 lines
3.8 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"todo-hetzner/app/internal/model"
|
|
)
|
|
|
|
type fakeStore struct {
|
|
todos []model.Todo
|
|
created string
|
|
toggled int64
|
|
deleted int64
|
|
pingErr error
|
|
}
|
|
|
|
func (s *fakeStore) List(context.Context) ([]model.Todo, error) { return s.todos, nil }
|
|
func (s *fakeStore) Create(_ context.Context, title string) error {
|
|
s.created = title
|
|
return nil
|
|
}
|
|
func (s *fakeStore) Toggle(_ context.Context, id int64) (bool, error) {
|
|
s.toggled = id
|
|
return id != 404, nil
|
|
}
|
|
func (s *fakeStore) Delete(_ context.Context, id int64) (bool, error) {
|
|
s.deleted = id
|
|
return id != 404, nil
|
|
}
|
|
func (s *fakeStore) Ping(context.Context) error { return s.pingErr }
|
|
|
|
func testHandler(t *testing.T, store *fakeStore) http.Handler {
|
|
t.Helper()
|
|
h, err := New(store, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return h.Routes()
|
|
}
|
|
|
|
func TestListRendersEscapedTodos(t *testing.T) {
|
|
router := testHandler(t, &fakeStore{todos: []model.Todo{{ID: 1, Title: "<script>alert(1)</script>"}}})
|
|
recorder := httptest.NewRecorder()
|
|
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("status = %d", recorder.Code)
|
|
}
|
|
if strings.Contains(recorder.Body.String(), "<script>") || !strings.Contains(recorder.Body.String(), "<script>") {
|
|
t.Fatalf("title was not escaped: %s", recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateValidatesAndTrimsTitle(t *testing.T) {
|
|
store := &fakeStore{}
|
|
router := testHandler(t, store)
|
|
form := url.Values{"title": {" ship it "}}
|
|
req := httptest.NewRequest(http.MethodPost, "/todos", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
recorder := httptest.NewRecorder()
|
|
router.ServeHTTP(recorder, req)
|
|
if recorder.Code != http.StatusSeeOther || store.created != "ship it" {
|
|
t.Fatalf("status = %d, created = %q", recorder.Code, store.created)
|
|
}
|
|
|
|
req = httptest.NewRequest(http.MethodPost, "/todos", strings.NewReader("title=++"))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
recorder = httptest.NewRecorder()
|
|
router.ServeHTTP(recorder, req)
|
|
if recorder.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("empty title status = %d", recorder.Code)
|
|
}
|
|
}
|
|
|
|
func TestMutationsAndNotFound(t *testing.T) {
|
|
store := &fakeStore{}
|
|
router := testHandler(t, store)
|
|
for _, tc := range []struct {
|
|
path string
|
|
want int
|
|
}{
|
|
{"/todos/12/toggle", http.StatusSeeOther},
|
|
{"/todos/13/delete", http.StatusSeeOther},
|
|
{"/todos/404/delete", http.StatusNotFound},
|
|
{"/todos/nope/toggle", http.StatusBadRequest},
|
|
} {
|
|
recorder := httptest.NewRecorder()
|
|
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, tc.path, nil))
|
|
if recorder.Code != tc.want {
|
|
t.Errorf("%s status = %d, want %d", tc.path, recorder.Code, tc.want)
|
|
}
|
|
}
|
|
if store.toggled != 12 || store.deleted != 404 {
|
|
t.Fatalf("toggled = %d, deleted = %d", store.toggled, store.deleted)
|
|
}
|
|
}
|
|
|
|
func TestHealth(t *testing.T) {
|
|
store := &fakeStore{}
|
|
router := testHandler(t, store)
|
|
for _, tc := range []struct {
|
|
err error
|
|
want int
|
|
}{{nil, http.StatusOK}, {errors.New("down"), http.StatusServiceUnavailable}} {
|
|
store.pingErr = tc.err
|
|
recorder := httptest.NewRecorder()
|
|
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
|
if recorder.Code != tc.want {
|
|
t.Errorf("status = %d, want %d", recorder.Code, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRejectsCrossOriginMutation(t *testing.T) {
|
|
router := testHandler(t, &fakeStore{})
|
|
req := httptest.NewRequest(http.MethodPost, "/todos/1/delete", nil)
|
|
req.Header.Set("Origin", "https://attacker.example")
|
|
recorder := httptest.NewRecorder()
|
|
router.ServeHTTP(recorder, req)
|
|
if recorder.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d", recorder.Code)
|
|
}
|
|
}
|