package main import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" ) func testServer(t *testing.T) *apiServer { t.Helper() globalLimiter = newLimiter() // fresh buckets per test store, err := OpenStore(":memory:") if err != nil { t.Fatal(err) } return &apiServer{store: store, cfg: Config{MaxTextBytes: 5 * 1024 * 1024, MaxItemBytes: 25 * 1024 * 1024}} } 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+"?password=hunter2", nil) 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+"?password=nope", nil) 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"` } json.Unmarshal(rec.Body.Bytes(), &created) req = httptest.NewRequest("DELETE", "/api/pastes/"+created.ID, nil) 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 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() - (softDeleteGraceDays+1)*86400 s.store.db.Exec(`UPDATE pastes SET deleted_at=? WHERE id=?`, past, created.ID) s.store.SweepExpired() var count int s.store.db.QueryRow(`SELECT COUNT(*) FROM pastes WHERE id=?`, created.ID).Scan(&count) if count != 0 { t.Fatal("expected hard delete after grace period") } } func TestSlugCharset(t *testing.T) { for i := 0; i < 100; i++ { s := genSlug(6) for _, c := range s { if !strings.ContainsRune(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) } }