summaryrefslogtreecommitdiff
path: root/internal/api/drops_test.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-16 20:43:28 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-16 20:43:28 +0000
commit7f920ca63af5329c19a0e5a879c649c594beea35 (patch)
tree803150e7c895d3232bad35c729aad647aaa54348 /internal/api/drops_test.go
parent072652f617653dce74368cedb42b88189e5014fb (diff)
feat: add web push notifications and file drop
Web Push: - WebPushNotifier with VAPID auth; urgency mapped to event type (BLOCKED=urgent, FAILED=high, COMPLETED=low) - Auto-generates VAPID keys on first serve, persists to config file - push_subscriptions table in SQLite (upsert by endpoint) - GET /api/push/vapid-key, POST/DELETE /api/push/subscribe endpoints - Service worker (sw.js) handles push events and notification clicks - Notification bell button in web UI; subscribes on click File Drop: - GET /api/drops, GET /api/drops/{filename}, POST /api/drops - Persistent ~/.claudomator/drops/ directory - CLAUDOMATOR_DROP_DIR env var passed to agent subprocesses - Drops tab (📁) in web UI with file listing and download links Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/api/drops_test.go')
-rw-r--r--internal/api/drops_test.go159
1 files changed, 159 insertions, 0 deletions
diff --git a/internal/api/drops_test.go b/internal/api/drops_test.go
new file mode 100644
index 0000000..ab67489
--- /dev/null
+++ b/internal/api/drops_test.go
@@ -0,0 +1,159 @@
+package api
+
+import (
+ "bytes"
+ "encoding/json"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func testServerWithDrops(t *testing.T) (*Server, string) {
+ t.Helper()
+ srv, _ := testServer(t)
+ dropsDir := t.TempDir()
+ srv.SetDropsDir(dropsDir)
+ return srv, dropsDir
+}
+
+func TestHandleListDrops_Empty(t *testing.T) {
+ srv, _ := testServerWithDrops(t)
+
+ req := httptest.NewRequest("GET", "/api/drops", nil)
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d", rec.Code)
+ }
+
+ var files []map[string]interface{}
+ if err := json.NewDecoder(rec.Body).Decode(&files); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if len(files) != 0 {
+ t.Errorf("want empty list, got %d entries", len(files))
+ }
+}
+
+func TestHandleListDrops_WithFile(t *testing.T) {
+ srv, dropsDir := testServerWithDrops(t)
+
+ // Create a file in the drops dir.
+ if err := os.WriteFile(filepath.Join(dropsDir, "hello.txt"), []byte("world"), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/drops", nil)
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d: %s", rec.Code, rec.Body.String())
+ }
+
+ var files []map[string]interface{}
+ if err := json.NewDecoder(rec.Body).Decode(&files); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if len(files) != 1 {
+ t.Fatalf("want 1 file, got %d", len(files))
+ }
+ if files[0]["name"] != "hello.txt" {
+ t.Errorf("name: want %q, got %v", "hello.txt", files[0]["name"])
+ }
+}
+
+func TestHandlePostDrop_Multipart(t *testing.T) {
+ srv, dropsDir := testServerWithDrops(t)
+
+ var buf bytes.Buffer
+ w := multipart.NewWriter(&buf)
+ fw, err := w.CreateFormFile("file", "test.txt")
+ if err != nil {
+ t.Fatal(err)
+ }
+ fw.Write([]byte("hello world")) //nolint:errcheck
+ w.Close()
+
+ req := httptest.NewRequest("POST", "/api/drops", &buf)
+ req.Header.Set("Content-Type", w.FormDataContentType())
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusCreated {
+ t.Fatalf("want 201, got %d: %s", rec.Code, rec.Body.String())
+ }
+
+ var resp map[string]interface{}
+ if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if resp["name"] != "test.txt" {
+ t.Errorf("name: want %q, got %v", "test.txt", resp["name"])
+ }
+
+ // Verify file was created on disk.
+ content, err := os.ReadFile(filepath.Join(dropsDir, "test.txt"))
+ if err != nil {
+ t.Fatalf("reading uploaded file: %v", err)
+ }
+ if string(content) != "hello world" {
+ t.Errorf("content: want %q, got %q", "hello world", content)
+ }
+}
+
+func TestHandleGetDrop_Download(t *testing.T) {
+ srv, dropsDir := testServerWithDrops(t)
+
+ if err := os.WriteFile(filepath.Join(dropsDir, "download.txt"), []byte("download me"), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/drops/download.txt", nil)
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d", rec.Code)
+ }
+
+ cd := rec.Header().Get("Content-Disposition")
+ if !strings.Contains(cd, "attachment") {
+ t.Errorf("want Content-Disposition: attachment, got %q", cd)
+ }
+ if rec.Body.String() != "download me" {
+ t.Errorf("body: want %q, got %q", "download me", rec.Body.String())
+ }
+}
+
+func TestHandleGetDrop_PathTraversal(t *testing.T) {
+ srv, _ := testServerWithDrops(t)
+
+ // Attempt path traversal — should be rejected.
+ req := httptest.NewRequest("GET", "/api/drops/..%2Fetc%2Fpasswd", nil)
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ // The Go net/http router will handle %2F-encoded slashes as literal characters,
+ // so the filename becomes "../etc/passwd". Our handler should reject it.
+ if rec.Code == http.StatusOK {
+ t.Error("expected non-200 for path traversal attempt")
+ }
+}
+
+func TestHandleGetDrop_NotFound(t *testing.T) {
+ srv, _ := testServerWithDrops(t)
+
+ req := httptest.NewRequest("GET", "/api/drops/notexist.txt", nil)
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("want 404, got %d", rec.Code)
+ }
+}