package api import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "palette/internal/store" ) // #4: cans appear in /api/public as normal rows with is_can=true. func TestCanInPublicListing(t *testing.T) { s := testServer(t) h := s.routes() // a can and a regular paste body, ct := multipartBody(t, map[string]string{ "title": "Listed can", "json_items": `[{"title":"a.txt","content":"AAA"}]`, }, "", "", "") req := httptest.NewRequest("POST", "/api/pastes/can", body) req.Header.Set("Content-Type", ct) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 201 { t.Fatalf("create can: %d %s", rec.Code, rec.Body.String()) } var created struct{ ID string `json:"id"` } json.Unmarshal(rec.Body.Bytes(), &created) req = httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"plain"}`)) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) req = httptest.NewRequest("GET", "/api/public", nil) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 200 { t.Fatalf("public: %d", rec.Code) } var got struct { Total int `json:"total"` Items []struct { ID string `json:"id"` IsCan bool `json:"is_can"` Title any `json:"title"` } `json:"items"` } json.Unmarshal(rec.Body.Bytes(), &got) if got.Total != 2 { t.Fatalf("expected total 2 (can + paste), got %d", got.Total) } foundCan := false for _, it := range got.Items { if it.ID == created.ID { if !it.IsCan { t.Fatalf("can row missing is_can flag") } foundCan = true } else if it.IsCan { t.Fatalf("plain paste flagged as can") } } if !foundCan { t.Fatalf("can not present in /api/public") } // #4: unlisted can must not be listed body2, ct2 := multipartBody(t, map[string]string{ "title": "Hidden can", "visibility": "unlisted", "json_items": `[{"title":"b.txt","content":"BBB"}]`, }, "", "", "") req = httptest.NewRequest("POST", "/api/pastes/can", body2) req.Header.Set("Content-Type", ct2) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 201 { t.Fatalf("unlisted can create: %d", rec.Code) } req = httptest.NewRequest("GET", "/api/public", nil) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if strings.Contains(rec.Body.String(), "Hidden can") { t.Fatalf("unlisted can leaked into /api/public") } } // #4: custom slug support for cans + conflict with existing paste slug. func TestCanCustomSlug(t *testing.T) { s := testServer(t) h := s.routes() body, ct := multipartBody(t, map[string]string{ "title": "Slugged", "custom_slug": "my-bundle", "json_items": `[{"title":"a.txt","content":"AAA"}]`, }, "", "", "") req := httptest.NewRequest("POST", "/api/pastes/can", body) req.Header.Set("Content-Type", ct) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 201 { t.Fatalf("create can with slug: %d %s", rec.Code, rec.Body.String()) } var created struct{ ID, URL string } json.Unmarshal(rec.Body.Bytes(), &created) if created.ID != "my-bundle" { t.Fatalf("expected id my-bundle, got %q", created.ID) } // custom slug resolves on the can page route req = httptest.NewRequest("GET", "/can/my-bundle", nil) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Slugged") { t.Fatalf("can page by slug: %d", rec.Code) } // duplicate slug rejected 409 body, ct = multipartBody(t, map[string]string{ "title": "Again", "custom_slug": "my-bundle", "json_items": `[{"title":"a.txt","content":"AAA"}]`, }, "", "", "") req = httptest.NewRequest("POST", "/api/pastes/can", body) req.Header.Set("Content-Type", ct) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 409 { t.Fatalf("duplicate slug expected 409, got %d", rec.Code) } // reserved slug rejected body, ct = multipartBody(t, map[string]string{ "title": "R", "custom_slug": "admin", "json_items": `[{"title":"a.txt","content":"AAA"}]`, }, "", "", "") req = httptest.NewRequest("POST", "/api/pastes/can", body) req.Header.Set("Content-Type", ct) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 409 { t.Fatalf("reserved slug expected 409, got %d", rec.Code) } } // #4: invalid expiry rejected on cans, mirroring paste behavior (#48). func TestCanExpiryValidation(t *testing.T) { s := testServer(t) h := s.routes() body, ct := multipartBody(t, map[string]string{ "title": "Bad expiry", "expires_in": "30s", "json_items": `[{"title":"a.txt","content":"AAA"}]`, }, "", "", "") req := httptest.NewRequest("POST", "/api/pastes/can", body) req.Header.Set("Content-Type", ct) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 400 { t.Fatalf("expected 400 for 30s expiry, got %d", rec.Code) } } // #4: expired cans 404 on the page and API. func TestCanExpiryAccess(t *testing.T) { s := testServer(t) h := s.routes() body, ct := multipartBody(t, map[string]string{ "title": "Dying", "expires_in": "1h", "json_items": `[{"title":"a.txt","content":"AAA"}]`, }, "", "", "") req := httptest.NewRequest("POST", "/api/pastes/can", body) req.Header.Set("Content-Type", ct) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) var created struct{ ID string `json:"id"` } json.Unmarshal(rec.Body.Bytes(), &created) // backdate expiry to force the expired path s.store.Exec(`UPDATE paste_cans SET expires_at=? WHERE id=?`, 100, created.ID) req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 404 { t.Fatalf("expired can API expected 404, got %d", rec.Code) } req = httptest.NewRequest("GET", "/can/"+created.ID, nil) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 404 { t.Fatalf("expired can page expected 404, got %d", rec.Code) } } // #4: password-protected can page uses the same unlock flow as pastes. func TestCanUnlockFlow(t *testing.T) { s := testServer(t) h := s.routes() body, ct := multipartBody(t, map[string]string{ "title": "Locked can", "password": "pw123", "json_items": `[{"title":"secret.txt","content":"sec content"}]`, }, "", "", "") req := httptest.NewRequest("POST", "/api/pastes/can", body) req.Header.Set("Content-Type", ct) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) var created struct{ ID string `json:"id"` } json.Unmarshal(rec.Body.Bytes(), &created) // GET page without cookie -> unlock form req = httptest.NewRequest("GET", "/can/"+created.ID, nil) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 200 || !strings.Contains(rec.Body.String(), "locked") { t.Fatalf("expected unlock form, got %d", rec.Code) } // POST wrong password -> unlock form with error fd := strings.NewReader("password=wrong") req = httptest.NewRequest("POST", "/can/"+created.ID, fd) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if !strings.Contains(rec.Body.String(), "Wrong password") { t.Fatalf("expected wrong-password message") } // POST correct password -> page renders, cookie set fd = strings.NewReader("password=pw123") req = httptest.NewRequest("POST", "/can/"+created.ID, fd) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if !strings.Contains(rec.Body.String(), "sec content") { t.Fatalf("unlocked can page missing item content") } var pwCookie *http.Cookie for _, c := range rec.Result().Cookies() { if c.Name == "pw_"+created.ID { pwCookie = c } } if pwCookie == nil { t.Fatalf("unlock cookie not set") } // GET with cookie -> unlocked req = httptest.NewRequest("GET", "/can/"+created.ID, nil) req.AddCookie(pwCookie) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if !strings.Contains(rec.Body.String(), "sec content") { t.Fatalf("cookie unlock failed: %d", rec.Code) } // a forged cookie value must not unlock req = httptest.NewRequest("GET", "/can/"+created.ID, nil) req.AddCookie(&http.Cookie{Name: "pw_" + created.ID, Value: "forged"}) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if strings.Contains(rec.Body.String(), "sec content") { t.Fatalf("forged cookie unlocked the can") } } // #4: can item access inherits the can unlock cookie (not just query param). func TestCanItemCookieParity(t *testing.T) { s := testServer(t) h := s.routes() body, ct := multipartBody(t, map[string]string{ "title": "Cookie can", "password": "pw123", "json_items": `[{"title":"s.txt","content":"sec"}]`, }, "", "", "") req := httptest.NewRequest("POST", "/api/pastes/can", body) req.Header.Set("Content-Type", ct) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) var created struct{ ID string `json:"id"` } json.Unmarshal(rec.Body.Bytes(), &created) // unlock via page to get the cookie fd := strings.NewReader("password=pw123") req = httptest.NewRequest("POST", "/can/"+created.ID, fd) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) var pwCookie *http.Cookie for _, c := range rec.Result().Cookies() { if c.Name == "pw_"+created.ID { pwCookie = c } } if pwCookie == nil { t.Fatalf("no unlock cookie") } // item id from API (with password query) req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil) req.Header.Set("X-Paste-Password", "pw123") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) var can struct { Items []struct{ ID string `json:"id"` } `json:"items"` } json.Unmarshal(rec.Body.Bytes(), &can) itemID := can.Items[0].ID // API item with the unlock cookie but no password -> 200 req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+itemID, nil) req.AddCookie(pwCookie) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 200 { t.Fatalf("item via cookie expected 200, got %d", rec.Code) } // forged cookie -> 401 req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+itemID, nil) req.AddCookie(&http.Cookie{Name: "pw_" + created.ID, Value: "forged"}) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 401 { t.Fatalf("forged cookie on item expected 401, got %d", rec.Code) } } // #4: viewer-scoped soft delete of cans. func TestCanDeleteParity(t *testing.T) { s := testServer(t) h := s.routes() body, ct := multipartBody(t, map[string]string{ "title": "Doomed", "json_items": `[{"title":"a.txt","content":"AAA"}]`, }, "", "", "") req := httptest.NewRequest("POST", "/api/pastes/can", body) req.Header.Set("Content-Type", ct) req.AddCookie(&http.Cookie{Name: "vwr", Value: "creator"}) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) var created struct{ ID string `json:"id"` } json.Unmarshal(rec.Body.Bytes(), &created) // delete without matching viewer -> 403 req = httptest.NewRequest("DELETE", "/api/cans/"+created.ID, nil) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 403 { t.Fatalf("unauthorized delete expected 403, got %d", rec.Code) } // creator's browser -> 200 req = httptest.NewRequest("DELETE", "/api/cans/"+created.ID, nil) req.AddCookie(&http.Cookie{Name: "vwr", Value: "creator"}) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 200 { t.Fatalf("creator delete expected 200, got %d", rec.Code) } // gone from API and page req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 404 { t.Fatalf("deleted can expected 404, got %d", rec.Code) } // gone from listings req = httptest.NewRequest("GET", "/api/public", nil) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if strings.Contains(rec.Body.String(), "Doomed") { t.Fatalf("deleted can still listed") } } // #4: cans appear in /api/mine for the creating browser. func TestCanInMine(t *testing.T) { s := testServer(t) h := s.routes() body, ct := multipartBody(t, map[string]string{ "title": "Mine can", "json_items": `[{"title":"a.txt","content":"AAA"}]`, }, "", "", "") req := httptest.NewRequest("POST", "/api/pastes/can", body) req.Header.Set("Content-Type", ct) req.AddCookie(&http.Cookie{Name: "vwr", Value: "v-mine"}) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 201 { t.Fatalf("create: %d", rec.Code) } req = httptest.NewRequest("GET", "/api/mine", nil) req.AddCookie(&http.Cookie{Name: "vwr", Value: "v-mine"}) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) var got struct { Items []struct { ID string `json:"id"` IsCan bool `json:"is_can"` } `json:"items"` } json.Unmarshal(rec.Body.Bytes(), &got) if len(got.Items) != 1 || !got.Items[0].IsCan { t.Fatalf("can missing from /api/mine: %s", rec.Body.String()) } } // #4: sweep soft-deletes expired cans (parity with pastes). func TestCanSweepExpired(t *testing.T) { s := testServer(t) s.store.CreateCan("sweepcan", "sweep", "", "public", nil, 1, nil, nil) s.store.InsertCanItem("sweepcan", "a.txt", "AAA", "text/plain", nil, nil, nil, 1) s.store.Exec(`UPDATE paste_cans SET expires_at=? WHERE id=?`, 100, "sweepcan") s.store.SweepExpired() if can, _ := s.store.GetCan("sweepcan"); can != nil { t.Fatalf("expired can survived sweep") } _ = store.GenSlug(4) // keep import if store pkg shrinks }