Cans UI + parity: unlock-cookie flow, /can page, listings badge, custom slug, delete, sweeper; #32 perf notes (#4, #32)
This commit is contained in:
@@ -0,0 +1,413 @@
|
||||
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+"?password=pw123", nil)
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user