diff --git a/backend/internal/web/handlers_tasks_test.go b/backend/internal/web/handlers_tasks_test.go new file mode 100644 index 0000000..4820cf0 --- /dev/null +++ b/backend/internal/web/handlers_tasks_test.go @@ -0,0 +1,692 @@ +package web + +// handlers_tasks_test.go — Wave 0 RED test scaffold for TASK-01..07 + IDOR test. +// +// All test functions call t.Skip("handlers_tasks not yet implemented") so they +// compile but do NOT fail before Plan 02 implements the handlers and routes. +// This file is the RED baseline; Plan 02 turns it green. +// +// Pattern: mirrors handlers_tablos_test.go exactly — setupTestDB, loginUser, +// preInsertUser, getCSRFToken, and testCSRFKey are reused from the existing +// test files in the same package. + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "backend/internal/auth" + "backend/internal/db/sqlc" + + "github.com/jackc/pgx/v5/pgtype" +) + +// TasksDeps holds dependencies for task handlers. +// Stub declared here so tests compile; will be moved to handlers_tasks.go in Plan 02. +type TasksDeps struct { + Queries *sqlc.Queries +} + +// newTaskTestRouter builds a router with both TablosDeps and TasksDeps wired. +// In Plan 02, TasksDeps will be passed to NewRouter; for now we pass only +// TablosDeps (which is what NewRouter currently accepts) so tests compile. +// The Task routes will be added to NewRouter in Plan 02. +func newTaskTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { + authDeps := AuthDeps{Queries: q, Store: store, Secure: false} + tabloDeps := TablosDeps{Queries: q} + return NewRouter(stubPinger{}, "./static", authDeps, tabloDeps, testCSRFKey, "dev", "localhost") +} + +// insertTestTablo is a helper that creates a tablo owned by the given user for +// use in task tests. +func insertTestTablo(t *testing.T, ctx context.Context, q *sqlc.Queries, userID interface{ GetID() interface{} }) sqlc.Tablo { + t.Helper() + t.Skip("handlers_tasks not yet implemented") + return sqlc.Tablo{} +} + +// ---- TestTasksKanbanRenders (TASK-01) ---- + +// TestTasksKanbanRenders verifies that GET /tablos/{id} by the owner renders +// all four kanban column headers: Todo, In Progress, In Review, Done (TASK-01). +func TestTasksKanbanRenders(t *testing.T) { + t.Skip("handlers_tasks not yet implemented") + + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newTaskTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "kanban@example.com", "correct-horse-12") + + tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ + UserID: user.ID, + Title: "Kanban Test Tablo", + Description: pgtype.Text{Valid: false}, + Color: pgtype.Text{Valid: false}, + }) + if err != nil { + t.Fatalf("InsertTablo: %v", err) + } + + cookieVal, _, storeErr := store.Create(ctx, user.ID) + if storeErr != nil { + t.Fatalf("store.Create: %v", storeErr) + } + sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: cookieVal} + + req := httptest.NewRequest(http.MethodGet, "/tablos/"+tablo.ID.String(), nil) + req.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("GET /tablos/{id} status = %d; want 200", rec.Code) + } + + body := rec.Body.String() + for _, col := range []string{"Todo", "In Progress", "In Review", "Done"} { + if !strings.Contains(body, col) { + t.Errorf("kanban board missing column header %q", col) + } + } +} + +// ---- TestTaskCreate (TASK-02) ---- + +// TestTaskCreate verifies that POST /tablos/{id}/tasks creates a task and +// returns a 200 response with an HTMX card fragment (TASK-02). +func TestTaskCreate(t *testing.T) { + t.Skip("handlers_tasks not yet implemented") + + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newTaskTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "taskcreate@example.com", "correct-horse-12") + + tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ + UserID: user.ID, + Title: "Task Create Tablo", + Description: pgtype.Text{Valid: false}, + Color: pgtype.Text{Valid: false}, + }) + if err != nil { + t.Fatalf("InsertTablo: %v", err) + } + + cookieVal, _, storeErr := store.Create(ctx, user.ID) + if storeErr != nil { + t.Fatalf("store.Create: %v", storeErr) + } + sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: cookieVal} + csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/"+tablo.ID.String(), []*http.Cookie{sessionCookie}) + + form := url.Values{ + "title": {"My Test Task"}, + "status": {"todo"}, + "_csrf": {csrfToken}, + } + req := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.ID.String()+"/tasks", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("HX-Request", "true") + for _, c := range csrfCookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("POST /tablos/{id}/tasks status = %d; want 200", rec.Code) + } + if !strings.Contains(rec.Body.String(), "My Test Task") { + t.Errorf("response body missing task title; body: %.300s", rec.Body.String()) + } + + // DB row must exist. + tasks, err := q.ListTasksByTablo(ctx, tablo.ID) + if err != nil { + t.Fatalf("ListTasksByTablo: %v", err) + } + if len(tasks) == 0 { + t.Fatal("no task row in DB after create") + } + if tasks[0].Title != "My Test Task" { + t.Errorf("DB task title = %q; want 'My Test Task'", tasks[0].Title) + } +} + +// ---- TestTaskCreateValidation (TASK-02) ---- + +// TestTaskCreateValidation verifies that POST /tablos/{id}/tasks with an empty +// title returns 422 with an error message (TASK-02 validation). +func TestTaskCreateValidation(t *testing.T) { + t.Skip("handlers_tasks not yet implemented") + + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newTaskTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "taskcreatevalidation@example.com", "correct-horse-12") + + tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ + UserID: user.ID, + Title: "Task Validation Tablo", + Description: pgtype.Text{Valid: false}, + Color: pgtype.Text{Valid: false}, + }) + if err != nil { + t.Fatalf("InsertTablo: %v", err) + } + + cookieVal, _, storeErr := store.Create(ctx, user.ID) + if storeErr != nil { + t.Fatalf("store.Create: %v", storeErr) + } + sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: cookieVal} + csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/"+tablo.ID.String(), []*http.Cookie{sessionCookie}) + + form := url.Values{ + "title": {""}, + "status": {"todo"}, + "_csrf": {csrfToken}, + } + req := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.ID.String()+"/tasks", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("HX-Request", "true") + for _, c := range csrfCookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("status = %d; want 422", rec.Code) + } + if !strings.Contains(rec.Body.String(), "Title is required") { + t.Errorf("body missing 'Title is required'; got: %.300s", rec.Body.String()) + } + + // No DB row should have been inserted. + tasks, _ := q.ListTasksByTablo(ctx, tablo.ID) + if len(tasks) != 0 { + t.Errorf("task count = %d; want 0 (no insert on validation error)", len(tasks)) + } +} + +// ---- TestTaskUpdate (TASK-03) ---- + +// TestTaskUpdate verifies that POST /tablos/{id}/tasks/{task_id} updates the +// task title and description and returns a card fragment (TASK-03). +func TestTaskUpdate(t *testing.T) { + t.Skip("handlers_tasks not yet implemented") + + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newTaskTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "taskupdate@example.com", "correct-horse-12") + + tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ + UserID: user.ID, + Title: "Task Update Tablo", + Description: pgtype.Text{Valid: false}, + Color: pgtype.Text{Valid: false}, + }) + if err != nil { + t.Fatalf("InsertTablo: %v", err) + } + + task, err := q.InsertTask(ctx, sqlc.InsertTaskParams{ + TabloID: tablo.ID, + Title: "Original Task Title", + Description: pgtype.Text{Valid: false}, + Status: sqlc.TaskStatusTodo, + Position: 100, + }) + if err != nil { + t.Fatalf("InsertTask: %v", err) + } + + cookieVal, _, storeErr := store.Create(ctx, user.ID) + if storeErr != nil { + t.Fatalf("store.Create: %v", storeErr) + } + sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: cookieVal} + csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/"+tablo.ID.String(), []*http.Cookie{sessionCookie}) + + form := url.Values{ + "title": {"Updated Task Title"}, + "description": {"Updated description"}, + "_csrf": {csrfToken}, + } + req := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.ID.String()+"/tasks/"+task.ID.String(), strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("HX-Request", "true") + for _, c := range csrfCookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("POST /tablos/{id}/tasks/{task_id} status = %d; want 200", rec.Code) + } + if !strings.Contains(rec.Body.String(), "Updated Task Title") { + t.Errorf("response body missing updated title; body: %.300s", rec.Body.String()) + } + + // Verify DB state. + updated, err := q.GetTaskByID(ctx, sqlc.GetTaskByIDParams{ID: task.ID, TabloID: tablo.ID}) + if err != nil { + t.Fatalf("GetTaskByID after update: %v", err) + } + if updated.Title != "Updated Task Title" { + t.Errorf("DB task title = %q; want 'Updated Task Title'", updated.Title) + } +} + +// ---- TestTaskReorderCrossColumn (TASK-04) ---- + +// TestTaskReorderCrossColumn verifies that POST /tablos/{id}/tasks/reorder +// changes the task's column (status) when dragged across columns (TASK-04). +func TestTaskReorderCrossColumn(t *testing.T) { + t.Skip("handlers_tasks not yet implemented") + + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newTaskTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "taskreordercross@example.com", "correct-horse-12") + + tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ + UserID: user.ID, + Title: "Reorder Cross Column Tablo", + Description: pgtype.Text{Valid: false}, + Color: pgtype.Text{Valid: false}, + }) + if err != nil { + t.Fatalf("InsertTablo: %v", err) + } + + task, err := q.InsertTask(ctx, sqlc.InsertTaskParams{ + TabloID: tablo.ID, + Title: "Cross Column Task", + Description: pgtype.Text{Valid: false}, + Status: sqlc.TaskStatusTodo, + Position: 100, + }) + if err != nil { + t.Fatalf("InsertTask: %v", err) + } + + cookieVal, _, storeErr := store.Create(ctx, user.ID) + if storeErr != nil { + t.Fatalf("store.Create: %v", storeErr) + } + sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: cookieVal} + csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/"+tablo.ID.String(), []*http.Cookie{sessionCookie}) + + // Move the task from "todo" to "in_progress". + form := url.Values{ + "task_id": {task.ID.String()}, + "status": {"in_progress"}, + "_csrf": {csrfToken}, + } + req := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.ID.String()+"/tasks/reorder", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("HX-Request", "true") + for _, c := range csrfCookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("POST /tablos/{id}/tasks/reorder status = %d; want 200", rec.Code) + } + + // Verify DB: task status should have changed to in_progress. + updated, err := q.GetTaskByID(ctx, sqlc.GetTaskByIDParams{ID: task.ID, TabloID: tablo.ID}) + if err != nil { + t.Fatalf("GetTaskByID after reorder: %v", err) + } + if updated.Status != sqlc.TaskStatusInProgress { + t.Errorf("task status = %q; want 'in_progress'", updated.Status) + } +} + +// ---- TestTaskReorderSameColumn (TASK-05) ---- + +// TestTaskReorderSameColumn verifies that POST /tablos/{id}/tasks/reorder +// changes the position of a task within the same column (TASK-05). +func TestTaskReorderSameColumn(t *testing.T) { + t.Skip("handlers_tasks not yet implemented") + + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newTaskTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "taskreordersame@example.com", "correct-horse-12") + + tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ + UserID: user.ID, + Title: "Reorder Same Column Tablo", + Description: pgtype.Text{Valid: false}, + Color: pgtype.Text{Valid: false}, + }) + if err != nil { + t.Fatalf("InsertTablo: %v", err) + } + + task1, err := q.InsertTask(ctx, sqlc.InsertTaskParams{ + TabloID: tablo.ID, + Title: "Task Position 100", + Description: pgtype.Text{Valid: false}, + Status: sqlc.TaskStatusTodo, + Position: 100, + }) + if err != nil { + t.Fatalf("InsertTask task1: %v", err) + } + + task2, err := q.InsertTask(ctx, sqlc.InsertTaskParams{ + TabloID: tablo.ID, + Title: "Task Position 200", + Description: pgtype.Text{Valid: false}, + Status: sqlc.TaskStatusTodo, + Position: 200, + }) + if err != nil { + t.Fatalf("InsertTask task2: %v", err) + } + + cookieVal, _, storeErr := store.Create(ctx, user.ID) + if storeErr != nil { + t.Fatalf("store.Create: %v", storeErr) + } + sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: cookieVal} + csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/"+tablo.ID.String(), []*http.Cookie{sessionCookie}) + + // Move task1 after task2 within the same column. + form := url.Values{ + "task_id": {task1.ID.String()}, + "status": {"todo"}, + "position": {"300"}, + "_csrf": {csrfToken}, + } + _ = task2 // referenced for test context + req := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.ID.String()+"/tasks/reorder", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("HX-Request", "true") + for _, c := range csrfCookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("POST /tablos/{id}/tasks/reorder status = %d; want 200", rec.Code) + } + + // Verify: task1 position updated. + updated, err := q.GetTaskByID(ctx, sqlc.GetTaskByIDParams{ID: task1.ID, TabloID: tablo.ID}) + if err != nil { + t.Fatalf("GetTaskByID after same-column reorder: %v", err) + } + if updated.Position == 100 { + t.Error("task position did not change after same-column reorder") + } +} + +// ---- TestTaskDelete (TASK-06) ---- + +// TestTaskDelete verifies that POST /tablos/{id}/tasks/{task_id}/delete removes +// the task and returns an empty div (TASK-06). +func TestTaskDelete(t *testing.T) { + t.Skip("handlers_tasks not yet implemented") + + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newTaskTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "taskdelete@example.com", "correct-horse-12") + + tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ + UserID: user.ID, + Title: "Task Delete Tablo", + Description: pgtype.Text{Valid: false}, + Color: pgtype.Text{Valid: false}, + }) + if err != nil { + t.Fatalf("InsertTablo: %v", err) + } + + task, err := q.InsertTask(ctx, sqlc.InsertTaskParams{ + TabloID: tablo.ID, + Title: "Task To Delete", + Description: pgtype.Text{Valid: false}, + Status: sqlc.TaskStatusTodo, + Position: 100, + }) + if err != nil { + t.Fatalf("InsertTask: %v", err) + } + + cookieVal, _, storeErr := store.Create(ctx, user.ID) + if storeErr != nil { + t.Fatalf("store.Create: %v", storeErr) + } + sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: cookieVal} + csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/"+tablo.ID.String(), []*http.Cookie{sessionCookie}) + + form := url.Values{"_csrf": {csrfToken}} + req := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.ID.String()+"/tasks/"+task.ID.String()+"/delete", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("HX-Request", "true") + for _, c := range csrfCookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("POST /tablos/{id}/tasks/{task_id}/delete status = %d; want 200", rec.Code) + } + + // DB row must be gone. + _, err = q.GetTaskByID(ctx, sqlc.GetTaskByIDParams{ID: task.ID, TabloID: tablo.ID}) + if err == nil { + t.Error("task row still exists in DB after delete") + } +} + +// ---- TestTaskOrderPersists (TASK-07) ---- + +// TestTaskOrderPersists verifies that after a reorder, a subsequent GET +// /tablos/{id} renders tasks in the new position order (TASK-07). +func TestTaskOrderPersists(t *testing.T) { + t.Skip("handlers_tasks not yet implemented") + + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newTaskTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "taskorderpersists@example.com", "correct-horse-12") + + tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ + UserID: user.ID, + Title: "Task Order Persists Tablo", + Description: pgtype.Text{Valid: false}, + Color: pgtype.Text{Valid: false}, + }) + if err != nil { + t.Fatalf("InsertTablo: %v", err) + } + + task1, err := q.InsertTask(ctx, sqlc.InsertTaskParams{ + TabloID: tablo.ID, + Title: "Task First", + Description: pgtype.Text{Valid: false}, + Status: sqlc.TaskStatusTodo, + Position: 100, + }) + if err != nil { + t.Fatalf("InsertTask task1: %v", err) + } + task2, err := q.InsertTask(ctx, sqlc.InsertTaskParams{ + TabloID: tablo.ID, + Title: "Task Second", + Description: pgtype.Text{Valid: false}, + Status: sqlc.TaskStatusTodo, + Position: 200, + }) + if err != nil { + t.Fatalf("InsertTask task2: %v", err) + } + + cookieVal, _, storeErr := store.Create(ctx, user.ID) + if storeErr != nil { + t.Fatalf("store.Create: %v", storeErr) + } + sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: cookieVal} + csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/"+tablo.ID.String(), []*http.Cookie{sessionCookie}) + + // Reorder: move task2 to position 50 (before task1 at 100). + form := url.Values{ + "task_id": {task2.ID.String()}, + "status": {"todo"}, + "position": {"50"}, + "_csrf": {csrfToken}, + } + reorderReq := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.ID.String()+"/tasks/reorder", strings.NewReader(form.Encode())) + reorderReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + reorderReq.Header.Set("HX-Request", "true") + for _, c := range csrfCookies { + reorderReq.AddCookie(c) + } + reorderRec := httptest.NewRecorder() + router.ServeHTTP(reorderRec, reorderReq) + + if reorderRec.Code != http.StatusOK { + t.Fatalf("reorder POST status = %d; want 200", reorderRec.Code) + } + + // Now fetch the tablo page and verify order. + getReq := httptest.NewRequest(http.MethodGet, "/tablos/"+tablo.ID.String(), nil) + getReq.AddCookie(sessionCookie) + getRec := httptest.NewRecorder() + router.ServeHTTP(getRec, getReq) + + if getRec.Code != http.StatusOK { + t.Fatalf("GET /tablos/{id} status = %d; want 200", getRec.Code) + } + body := getRec.Body.String() + + // task2 ("Task Second") should appear before task1 ("Task First") after reorder. + idx1 := strings.Index(body, task1.Title) + idx2 := strings.Index(body, task2.Title) + if idx1 != -1 && idx2 != -1 && idx2 > idx1 { + t.Errorf("task order not persisted: task2 index=%d, task1 index=%d (task2 should be first)", idx2, idx1) + } +} + +// ---- TestTaskOwnership (T-04-IDOR) ---- + +// TestTaskOwnership verifies that GET and POST task routes accessed by a +// non-owner return 404 (no information leakage, D-04) (T-04-IDOR). +func TestTaskOwnership(t *testing.T) { + t.Skip("handlers_tasks not yet implemented") + + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newTaskTestRouter(q, store) + + // User A creates a tablo + task. + userA := preInsertUser(t, ctx, q, "taskiodra@example.com", "correct-horse-12") + tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ + UserID: userA.ID, + Title: "UserA Secret Tablo", + Description: pgtype.Text{Valid: false}, + Color: pgtype.Text{Valid: false}, + }) + if err != nil { + t.Fatalf("InsertTablo: %v", err) + } + task, err := q.InsertTask(ctx, sqlc.InsertTaskParams{ + TabloID: tablo.ID, + Title: "UserA Secret Task", + Description: pgtype.Text{Valid: false}, + Status: sqlc.TaskStatusTodo, + Position: 100, + }) + if err != nil { + t.Fatalf("InsertTask: %v", err) + } + + // User B tries to access userA's task routes. + userB := preInsertUser(t, ctx, q, "taskiodorb@example.com", "correct-horse-12") + cookieVal, _, storeErr := store.Create(ctx, userB.ID) + if storeErr != nil { + t.Fatalf("store.Create for userB: %v", storeErr) + } + sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: cookieVal} + + routes := []struct { + method string + path string + }{ + {http.MethodPost, "/tablos/" + tablo.ID.String() + "/tasks/" + task.ID.String()}, + {http.MethodPost, "/tablos/" + tablo.ID.String() + "/tasks/" + task.ID.String() + "/delete"}, + } + + for _, route := range routes { + req := httptest.NewRequest(route.method, route.path, nil) + req.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + // D-04: 404 (not 403) — no information leakage. + if rec.Code != http.StatusNotFound { + t.Errorf("%s %s as non-owner: status = %d; want 404", route.method, route.path, rec.Code) + } + } +} diff --git a/backend/templates/tasks_forms.go b/backend/templates/tasks_forms.go new file mode 100644 index 0000000..3a7953a --- /dev/null +++ b/backend/templates/tasks_forms.go @@ -0,0 +1,30 @@ +package templates + +// TaskCreateForm carries submitted field values back to the template for +// repopulation on validation failure. +type TaskCreateForm struct { + Title string + Status string // holds the column status value, e.g. "todo" +} + +// TaskCreateErrors holds per-field and general error messages for the task +// create form. An empty string means "no error for this field". +type TaskCreateErrors struct { + Title string + General string +} + +// TaskUpdateForm carries submitted field values back to the template for +// repopulation on validation failure. +type TaskUpdateForm struct { + Title string + Description string +} + +// TaskUpdateErrors holds per-field and general error messages for the task +// update/edit form. An empty string means "no error for this field". +type TaskUpdateErrors struct { + Title string + Description string + General string +}