Query strings leak into Traefik access logs, browser history, and Referer headers. Admin key is now accepted only via X-Admin-Key; paste and can passwords only via X-Paste-Password (or the POST unlock form). Tests updated; new negative cases assert 401 for the query paths.
415 lines
13 KiB
Go
415 lines
13 KiB
Go
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
|
|
}
|