package api import ( "palette/internal/store" "palette/internal/web" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" ) func testServer(t *testing.T) *apiServer { t.Helper() globalLimiter = newLimiter() // fresh buckets per test ui, err := web.New() if err != nil { t.Fatal(err) } st, err := store.OpenStore(":memory:") if err != nil { t.Fatal(err) } cfg := Config{MaxTextBytes: 5 * 1024 * 1024, MaxItemBytes: 25 * 1024 * 1024} ss := NewTestSettingsStore(t, cfg) globalSettingsFn = ss.get t.Cleanup(func() { globalSettingsFn = nil }) return &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"} } func TestCreateAndGetPaste(t *testing.T) { s := testServer(t) h := s.routes() // create body := `{"content":"hello world","language":"txt"}` req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body)) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 201 { t.Fatalf("create: got %d want 201: %s", rec.Code, rec.Body.String()) } var created struct { ID string `json:"id"` } json.Unmarshal(rec.Body.Bytes(), &created) if len(created.ID) != 6 { t.Fatalf("unexpected id: %q", created.ID) } // get req = httptest.NewRequest("GET", "/api/pastes/"+created.ID, nil) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 200 { t.Fatalf("get: got %d", rec.Code) } var got map[string]any json.Unmarshal(rec.Body.Bytes(), &got) if got["content"] != "hello world" { t.Fatalf("content mismatch: %v", got["content"]) } if got["title"] != nil { t.Fatalf("title should be null, got %v", got["title"]) } } func TestPasswordProtection(t *testing.T) { s := testServer(t) h := s.routes() body := `{"content":"secret","password":"hunter2"}` req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body)) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) var created struct { ID string `json:"id"` } json.Unmarshal(rec.Body.Bytes(), &created) // without password -> 401 req = httptest.NewRequest("GET", "/api/pastes/"+created.ID, nil) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 401 { t.Fatalf("expected 401, got %d", rec.Code) } // with password -> 200 req = httptest.NewRequest("GET", "/api/pastes/"+created.ID, nil) req.Header.Set("X-Paste-Password", "hunter2") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 200 { t.Fatalf("expected 200 with password, got %d", rec.Code) } // wrong password -> 401 req = httptest.NewRequest("GET", "/api/pastes/"+created.ID, nil) req.Header.Set("X-Paste-Password", "nope") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 401 { t.Fatalf("expected 401 wrong pw, got %d", rec.Code) } } func TestExpiryValidation(t *testing.T) { s := testServer(t) h := s.routes() req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"x","expires_in":"notaduration"}`)) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 400 { t.Fatalf("expected 400, got %d", rec.Code) } } func TestSizeLimit(t *testing.T) { s := testServer(t) h := s.routes() big := strings.Repeat("a", int(s.cfg.MaxTextBytes)+1) req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"`+big+`"}`)) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 413 { t.Fatalf("expected 413, got %d", rec.Code) } } func TestSoftDelete(t *testing.T) { s := testServer(t) h := s.routes() req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"bye"}`)) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) var created struct { ID string `json:"id"` DeletionToken string `json:"deletion_token"` } json.Unmarshal(rec.Body.Bytes(), &created) req = httptest.NewRequest("DELETE", "/api/pastes/"+created.ID, nil) req.Header.Set("Authorization", "Bearer "+created.DeletionToken) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 200 { t.Fatalf("delete: got %d", rec.Code) } req = httptest.NewRequest("GET", "/api/pastes/"+created.ID, nil) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 404 { t.Fatalf("expected 404 after delete, got %d", rec.Code) } } func TestListPublicExcludesUnlisted(t *testing.T) { s := testServer(t) h := s.routes() for _, vis := range []string{"public", "unlisted"} { body := `{"content":"x","visibility":"` + vis + `"}` req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body)) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) } req := httptest.NewRequest("GET", "/api/public", nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) var resp struct { Total int `json:"total"` Items []map[string]any `json:"items"` } json.Unmarshal(rec.Body.Bytes(), &resp) if resp.Total != 1 { t.Fatalf("expected 1 public paste, got %d", resp.Total) } } func TestListPublicExcludesPasswordAndUnlisted(t *testing.T) { s := testServer(t) h := s.routes() bodies := []string{ `{"content":"open","visibility":"public"}`, `{"content":"locked","visibility":"public","password":"hunter2"}`, `{"content":"hidden","visibility":"unlisted"}`, } for _, body := range bodies { req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body)) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 201 { t.Fatalf("create %s: got %d", body, rec.Code) } } req := httptest.NewRequest("GET", "/api/public", nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 200 { t.Fatalf("list public: got %d", rec.Code) } var resp struct { Total int `json:"total"` Items []map[string]any `json:"items"` } json.Unmarshal(rec.Body.Bytes(), &resp) if resp.Total != 1 || len(resp.Items) != 1 { t.Fatalf("expected only the 1 public paste, got total=%d items=%d", resp.Total, len(resp.Items)) } // password-protected and unlisted pastes must not appear (no metadata leak) for _, secret := range []string{"hunter2", "locked", "hidden"} { if strings.Contains(rec.Body.String(), secret) { t.Fatalf("leaked %q in /api/public response", secret) } } } func TestSweepSoftDeletesAfterGrace(t *testing.T) { s := testServer(t) h := s.routes() req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"gone soon"}`)) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) var created struct { ID string `json:"id"` } json.Unmarshal(rec.Body.Bytes(), &created) s.store.SoftDelete(created.ID) // simulate grace elapsed past := time.Now().Unix() - (store.SoftDeleteGraceDays+1)*86400 s.store.Exec(`UPDATE pastes SET deleted_at=? WHERE id=?`, past, created.ID) s.store.SweepExpired() var count int count = s.store.QueryInt(`SELECT COUNT(*) FROM pastes WHERE id=?`, created.ID) if count != 0 { t.Fatal("expected hard delete after grace period") } } func TestSlugCharset(t *testing.T) { for i := 0; i < 100; i++ { s := store.GenSlug(6) for _, c := range s { if !strings.ContainsRune(store.SlugAlphabet, c) { t.Fatalf("bad char %q in slug %q", c, s) } } } } func TestRawEndpoint(t *testing.T) { s := testServer(t) h := s.routes() req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"raw content here"}`)) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) var created struct { ID string `json:"id"` } json.Unmarshal(rec.Body.Bytes(), &created) req = httptest.NewRequest("GET", "/raw/"+created.ID, nil) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 200 { t.Fatalf("raw: got %d", rec.Code) } if rec.Body.String() != "raw content here" { t.Fatalf("raw body mismatch: %q", rec.Body.String()) } if ct := rec.Header().Get("Content-Type"); ct != "text/plain" { t.Fatalf("raw content-type: %q", ct) } } func TestNotFound(t *testing.T) { s := testServer(t) h := s.routes() req := httptest.NewRequest("GET", "/api/pastes/zzzzzz", nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d", rec.Code) } }