diff options
| -rw-r--r-- | CLAUDE.md | 10 | ||||
| -rw-r--r-- | internal/handlers/handlers_test.go | 74 | ||||
| -rw-r--r-- | internal/handlers/settings.go | 15 | ||||
| -rw-r--r-- | web/templates/partials/timeline-tab.html | 16 | ||||
| -rw-r--r-- | web/templates/settings.html | 15 |
5 files changed, 102 insertions, 28 deletions
@@ -30,7 +30,15 @@ This project uses a three-role development workflow. **Read your role definition - **Dependency Awareness:** Use `go test`, `go build`, and `lint` to verify state rather than asking the agent to "think" through logic. - **Proactive Checkpointing:** Treat every 3-4 turns as a potential session end. Update `SESSION_STATE.md` frequently. -## Workflow: Plan-then-Execute +## Workflow: TDD — ALWAYS Write Tests First +**Every bug fix and feature MUST follow Test-Driven Development:** +1. **Red:** Write a failing test that reproduces the bug or specifies the new behavior. +2. **Green:** Write the minimum code to make the test pass. +3. **Refactor:** Clean up if needed, keeping tests green. + +**No exceptions.** Do not skip to writing production code. If you find a bug, write a test that fails first, then fix it. This applies to template assertions, handler logic, store queries, and JS behavior (via template content tests). + +### Plan-then-Execute 1. **Discovery:** Use terminal tools to locate relevant Go code/handlers. 2. **Planning:** Propose a specific plan and **wait for user confirmation** before editing. 3. **Execution:** Apply changes incrementally, verifying with `go test ./...`. diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index d338cd3..1842c38 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -1958,6 +1958,80 @@ func TestDashboardTemplate_HasSettingsLink(t *testing.T) { } } +// TestSettingsTemplate_HasCSRFToken verifies the settings page exposes a CSRF +// token in a meta tag so that JavaScript (e.g. passkey registration) can include +// it in POST requests. Without this, passkey registration fails with 403. +func TestSettingsTemplate_HasCSRFToken(t *testing.T) { + content, err := os.ReadFile("../../web/templates/settings.html") + if err != nil { + t.Skipf("Cannot read template file: %v", err) + } + tmpl := string(content) + + // The template must include a meta tag or hidden input with the Go template + // variable .CSRFToken so JS can retrieve it + if !strings.Contains(tmpl, ".CSRFToken") { + t.Error("settings.html must expose .CSRFToken (e.g. via meta tag) for JavaScript passkey registration") + } +} + +// TestHandleSettingsPage_PassesCSRFToken verifies the settings handler includes +// a CSRFToken in its template data so the passkey registration JS can use it. +func TestHandleSettingsPage_PassesCSRFToken(t *testing.T) { + h, cleanup := setupTestHandler(t) + defer cleanup() + + req := httptest.NewRequest("GET", "/settings", nil) + w := httptest.NewRecorder() + + h.HandleSettingsPage(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Expected status 200, got %d", w.Code) + } + + mock := h.renderer.(*MockRenderer) + if len(mock.Calls) == 0 { + t.Fatal("Expected renderer to be called") + } + + lastCall := mock.Calls[len(mock.Calls)-1] + // Use reflection or type assertion to check for CSRFToken field + // The data struct should have a CSRFToken field + type hasCSRF interface{ GetCSRFToken() string } + + // Check via fmt to inspect the struct fields + dataStr := fmt.Sprintf("%+v", lastCall.Data) + if !strings.Contains(dataStr, "CSRFToken") { + t.Error("Settings page template data must include a CSRFToken field for passkey registration") + } +} + +// TestTimelineTemplate_CheckboxesTargetSelf verifies that completion checkboxes +// in the timeline calendar grid target their parent item, not #tab-content. +func TestTimelineTemplate_CheckboxesTargetSelf(t *testing.T) { + content, err := os.ReadFile("../../web/templates/partials/timeline-tab.html") + if err != nil { + t.Skipf("Cannot read template file: %v", err) + } + tmpl := string(content) + + // Find all lines with complete-atom/uncomplete-atom that also reference hx-target + lines := strings.Split(tmpl, "\n") + for i, line := range lines { + if !strings.Contains(line, "complete-atom") { + continue + } + // Look at surrounding lines for the hx-target + for j := i; j < len(lines) && j < i+8; j++ { + if strings.Contains(lines[j], `hx-target="#tab-content"`) { + t.Errorf("Line %d: checkbox targeting #tab-content will replace entire view on complete. "+ + "Should target closest parent element instead.", j+1) + } + } + } +} + func TestHandleTimeline_WithParams(t *testing.T) { h, cleanup := setupTestHandler(t) defer cleanup() diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go index fa1acee..60fc6be 100644 --- a/internal/handlers/settings.go +++ b/internal/handlers/settings.go @@ -6,6 +6,7 @@ import ( "github.com/go-chi/chi/v5" + "task-dashboard/internal/auth" "task-dashboard/internal/models" ) @@ -21,13 +22,15 @@ func (h *Handler) HandleSettingsPage(w http.ResponseWriter, r *http.Request) { } data := struct { - Configs map[string][]models.SourceConfig - Sources []string - Toggles []models.FeatureToggle + Configs map[string][]models.SourceConfig + Sources []string + Toggles []models.FeatureToggle + CSRFToken string }{ - Configs: bySource, - Sources: []string{"trello", "todoist", "gcal", "gtasks"}, - Toggles: toggles, + Configs: bySource, + Sources: []string{"trello", "todoist", "gcal", "gtasks"}, + Toggles: toggles, + CSRFToken: auth.GetCSRFTokenFromContext(r.Context()), } if err := h.renderer.Render(w, "settings.html", data); err != nil { diff --git a/web/templates/partials/timeline-tab.html b/web/templates/partials/timeline-tab.html index c73c1b5..4979744 100644 --- a/web/templates/partials/timeline-tab.html +++ b/web/templates/partials/timeline-tab.html @@ -134,8 +134,8 @@ {{if .IsCompleted}}checked{{end}} hx-post="{{if .IsCompleted}}/uncomplete-atom{{else}}/complete-atom{{end}}" hx-vals='{"id": "{{.ID}}", "source": "{{.Source}}"{{if .ListID}}, "listId": "{{.ListID}}"{{end}}}' - hx-target="#tab-content" - hx-swap="innerHTML" + hx-target="closest .untimed-item" + hx-swap="outerHTML" class="h-4 w-4 rounded bg-black/40 border-white/30 text-white/80 focus:ring-white/30 cursor-pointer flex-shrink-0"> {{end}} <span class="{{if .IsCompleted}}line-through text-white/50{{end}}">{{.Title}}</span> @@ -186,8 +186,8 @@ {{if .IsCompleted}}checked{{end}} hx-post="{{if .IsCompleted}}/uncomplete-atom{{else}}/complete-atom{{end}}" hx-vals='{"id": "{{.ID}}", "source": "{{.Source}}"{{if .ListID}}, "listId": "{{.ListID}}"{{end}}}' - hx-target="#tab-content" - hx-swap="innerHTML" + hx-target="closest .calendar-event" + hx-swap="outerHTML" onclick="event.stopPropagation();" class="h-4 w-4 rounded bg-black/40 border-white/30 text-white/80 focus:ring-white/30 cursor-pointer flex-shrink-0"> {{end}} @@ -223,8 +223,8 @@ {{if .IsCompleted}}checked{{end}} hx-post="{{if .IsCompleted}}/uncomplete-atom{{else}}/complete-atom{{end}}" hx-vals='{"id": "{{.ID}}", "source": "{{.Source}}"{{if .ListID}}, "listId": "{{.ListID}}"{{end}}}' - hx-target="#tab-content" - hx-swap="innerHTML" + hx-target="closest .untimed-item" + hx-swap="outerHTML" class="h-4 w-4 rounded bg-black/40 border-white/30 text-white/80 focus:ring-white/30 cursor-pointer flex-shrink-0"> {{end}} <span class="{{if .IsCompleted}}line-through text-white/50{{end}}">{{.Title}}</span> @@ -271,8 +271,8 @@ {{if .IsCompleted}}checked{{end}} hx-post="{{if .IsCompleted}}/uncomplete-atom{{else}}/complete-atom{{end}}" hx-vals='{"id": "{{.ID}}", "source": "{{.Source}}"{{if .ListID}}, "listId": "{{.ListID}}"{{end}}}' - hx-target="#tab-content" - hx-swap="innerHTML" + hx-target="closest .calendar-event" + hx-swap="outerHTML" onclick="event.stopPropagation();" class="h-4 w-4 rounded bg-black/40 border-white/30 text-white/80 focus:ring-white/30 cursor-pointer flex-shrink-0"> {{end}} diff --git a/web/templates/settings.html b/web/templates/settings.html index 50569e4..0803ae3 100644 --- a/web/templates/settings.html +++ b/web/templates/settings.html @@ -3,6 +3,7 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <meta name="csrf-token" content="{{.CSRFToken}}"> <title>Settings - Task Dashboard</title> <script src="https://unpkg.com/htmx.org@1.9.10"></script> <style> @@ -237,19 +238,7 @@ statusEl.textContent = 'Starting registration...'; try { - const csrfMeta = document.querySelector('input[name="csrf_token"]') || document.querySelector('[name="csrf_token"]'); - let csrfToken = ''; - // Get CSRF token from the feature toggle form's hidden fields or cookie - const forms = document.querySelectorAll('form'); - for (const f of forms) { - const input = f.querySelector('input[name="csrf_token"]'); - if (input) { csrfToken = input.value; break; } - } - // Fallback: fetch from HTMX headers - if (!csrfToken) { - const resp = await fetch('/settings/passkeys'); - // Try to extract from response - } + const csrfToken = document.querySelector('meta[name="csrf-token"]').content; const beginResp = await fetch('/passkeys/register/begin', { method: 'POST', |
