1035 lines
31 KiB
Go
1035 lines
31 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"mime"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"scratchbox/internal/config"
|
|
"scratchbox/internal/storage"
|
|
)
|
|
|
|
func TestCreateReadThenExpireScratch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestHandler(t, testServerOptions{
|
|
maxUploadBytes: 1024,
|
|
defaultTTL: 120 * time.Millisecond,
|
|
rateLimitEnable: false,
|
|
})
|
|
|
|
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString("hello scratch"))
|
|
createReq.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
|
createReq.RemoteAddr = "127.0.0.1:9999"
|
|
createRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(createRec, createReq)
|
|
if createRec.Code != http.StatusCreated {
|
|
t.Fatalf("create status = %d, want %d", createRec.Code, http.StatusCreated)
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode create payload error = %v", err)
|
|
}
|
|
id, _ := payload["id"].(string)
|
|
if id == "" {
|
|
t.Fatalf("create payload missing id")
|
|
}
|
|
|
|
metaReq := httptest.NewRequest(http.MethodGet, "/api/scratch/"+id, nil)
|
|
metaReq.RemoteAddr = "127.0.0.1:9999"
|
|
metaRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(metaRec, metaReq)
|
|
if metaRec.Code != http.StatusOK {
|
|
t.Fatalf("read metadata status = %d, want %d", metaRec.Code, http.StatusOK)
|
|
}
|
|
|
|
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
|
|
rawReq.RemoteAddr = "127.0.0.1:9999"
|
|
rawRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rawRec, rawReq)
|
|
if rawRec.Code != http.StatusOK {
|
|
t.Fatalf("read raw status = %d, want %d", rawRec.Code, http.StatusOK)
|
|
}
|
|
if got := rawRec.Body.String(); got != "hello scratch" {
|
|
t.Fatalf("raw body = %q, want %q", got, "hello scratch")
|
|
}
|
|
|
|
time.Sleep(180 * time.Millisecond)
|
|
|
|
expiredReq := httptest.NewRequest(http.MethodGet, "/api/scratch/"+id, nil)
|
|
expiredReq.RemoteAddr = "127.0.0.1:9999"
|
|
expiredRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(expiredRec, expiredReq)
|
|
if expiredRec.Code != http.StatusGone {
|
|
t.Fatalf("expired status = %d, want %d", expiredRec.Code, http.StatusGone)
|
|
}
|
|
}
|
|
|
|
func TestUploadedScratchViewAndRawBeforeAndAfterExpiry(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestHandler(t, testServerOptions{
|
|
maxUploadBytes: 1024,
|
|
defaultTTL: 120 * time.Millisecond,
|
|
rateLimitEnable: false,
|
|
})
|
|
|
|
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString("hello from upload flow"))
|
|
createReq.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
|
createReq.RemoteAddr = "127.0.0.1:5090"
|
|
createRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(createRec, createReq)
|
|
if createRec.Code != http.StatusCreated {
|
|
t.Fatalf("create status = %d, want %d", createRec.Code, http.StatusCreated)
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode create payload error = %v", err)
|
|
}
|
|
id, _ := payload["id"].(string)
|
|
if id == "" {
|
|
t.Fatalf("create payload missing id")
|
|
}
|
|
|
|
viewReq := httptest.NewRequest(http.MethodGet, "/s/"+id, nil)
|
|
viewReq.RemoteAddr = "127.0.0.1:5091"
|
|
viewRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(viewRec, viewReq)
|
|
if viewRec.Code != http.StatusOK {
|
|
t.Fatalf("view-before-expiry status = %d, want %d", viewRec.Code, http.StatusOK)
|
|
}
|
|
if !strings.Contains(viewRec.Body.String(), `id="app"`) {
|
|
t.Fatalf("expected SPA shell body for /s/{id}, got %q", viewRec.Body.String())
|
|
}
|
|
|
|
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
|
|
rawReq.RemoteAddr = "127.0.0.1:5092"
|
|
rawRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rawRec, rawReq)
|
|
if rawRec.Code != http.StatusOK {
|
|
t.Fatalf("raw-before-expiry status = %d, want %d", rawRec.Code, http.StatusOK)
|
|
}
|
|
if got := rawRec.Body.String(); got != "hello from upload flow" {
|
|
t.Fatalf("raw-before-expiry body = %q, want %q", got, "hello from upload flow")
|
|
}
|
|
|
|
time.Sleep(180 * time.Millisecond)
|
|
|
|
viewExpiredReq := httptest.NewRequest(http.MethodGet, "/s/"+id, nil)
|
|
viewExpiredReq.RemoteAddr = "127.0.0.1:5093"
|
|
viewExpiredRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(viewExpiredRec, viewExpiredReq)
|
|
if viewExpiredRec.Code != http.StatusGone {
|
|
t.Fatalf("view-after-expiry status = %d, want %d", viewExpiredRec.Code, http.StatusGone)
|
|
}
|
|
if !strings.Contains(viewExpiredRec.Body.String(), `id="app"`) {
|
|
t.Fatalf("expected SPA shell body for expired /s/{id}, got %q", viewExpiredRec.Body.String())
|
|
}
|
|
|
|
rawExpiredReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
|
|
rawExpiredReq.RemoteAddr = "127.0.0.1:5094"
|
|
rawExpiredRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rawExpiredRec, rawExpiredReq)
|
|
if rawExpiredRec.Code != http.StatusGone {
|
|
t.Fatalf("raw-after-expiry status = %d, want %d", rawExpiredRec.Code, http.StatusGone)
|
|
}
|
|
if rawExpiredRec.Body.Len() != 0 {
|
|
t.Fatalf("expected empty raw body after expiry, got %q", rawExpiredRec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateScratchRejectsBodyOverLimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestHandler(t, testServerOptions{
|
|
maxUploadBytes: 8,
|
|
defaultTTL: time.Hour,
|
|
rateLimitEnable: false,
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString("this is too large"))
|
|
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
|
req.RemoteAddr = "127.0.0.1:5050"
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusRequestEntityTooLarge)
|
|
}
|
|
if !bytes.Contains(rec.Body.Bytes(), []byte("upload exceeds limits.max_upload_size")) {
|
|
t.Fatalf("unexpected body: %q", rec.Body.String())
|
|
}
|
|
if !bytes.Contains(rec.Body.Bytes(), []byte("(8 bytes)")) {
|
|
t.Fatalf("expected response to include configured max size, got: %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateScratchRejectsMultipartFileOverLimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestHandler(t, testServerOptions{
|
|
maxUploadBytes: 8,
|
|
defaultTTL: time.Hour,
|
|
rateLimitEnable: false,
|
|
})
|
|
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
file, err := writer.CreateFormFile("file", "too-big.txt")
|
|
if err != nil {
|
|
t.Fatalf("CreateFormFile() error = %v", err)
|
|
}
|
|
if _, err := file.Write([]byte("this is too large")); err != nil {
|
|
t.Fatalf("write file error = %v", err)
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
t.Fatalf("multipart close error = %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/scratch", &body)
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
req.RemoteAddr = "127.0.0.1:5060"
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusRequestEntityTooLarge)
|
|
}
|
|
if !bytes.Contains(rec.Body.Bytes(), []byte("upload exceeds limits.max_upload_size")) {
|
|
t.Fatalf("unexpected body: %q", rec.Body.String())
|
|
}
|
|
if !bytes.Contains(rec.Body.Bytes(), []byte("(8 bytes)")) {
|
|
t.Fatalf("expected response to include configured max size, got: %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateScratchRejectsMultipleMultipartFiles(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestHandler(t, testServerOptions{
|
|
maxUploadBytes: 1024,
|
|
defaultTTL: time.Hour,
|
|
rateLimitEnable: false,
|
|
})
|
|
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
file1, err := writer.CreateFormFile("file", "first.txt")
|
|
if err != nil {
|
|
t.Fatalf("CreateFormFile first error = %v", err)
|
|
}
|
|
if _, err := file1.Write([]byte("first")); err != nil {
|
|
t.Fatalf("write first file error = %v", err)
|
|
}
|
|
file2, err := writer.CreateFormFile("file", "second.txt")
|
|
if err != nil {
|
|
t.Fatalf("CreateFormFile second error = %v", err)
|
|
}
|
|
if _, err := file2.Write([]byte("second")); err != nil {
|
|
t.Fatalf("write second file error = %v", err)
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
t.Fatalf("multipart close error = %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/scratch", &body)
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
req.RemoteAddr = "127.0.0.1:5051"
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
|
}
|
|
if !bytes.Contains(rec.Body.Bytes(), []byte("only one file is allowed per scratch upload")) {
|
|
t.Fatalf("unexpected body: %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateScratchMultipartContentOnly(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestHandler(t, testServerOptions{
|
|
maxUploadBytes: 1024,
|
|
defaultTTL: time.Hour,
|
|
rateLimitEnable: false,
|
|
})
|
|
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
if err := writer.WriteField("content", "hello from content field"); err != nil {
|
|
t.Fatalf("WriteField() error = %v", err)
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
t.Fatalf("multipart close error = %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/scratch", &body)
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
req.RemoteAddr = "127.0.0.1:5052"
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusCreated)
|
|
}
|
|
}
|
|
|
|
func TestCreateScratchMultipartPreservesFilenameForRawDownload(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestHandler(t, testServerOptions{
|
|
maxUploadBytes: 1024,
|
|
defaultTTL: time.Hour,
|
|
rateLimitEnable: false,
|
|
})
|
|
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
file, err := writer.CreateFormFile("file", "example upload.txt")
|
|
if err != nil {
|
|
t.Fatalf("CreateFormFile() error = %v", err)
|
|
}
|
|
if _, err := file.Write([]byte("uploaded file body")); err != nil {
|
|
t.Fatalf("write file error = %v", err)
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
t.Fatalf("multipart close error = %v", err)
|
|
}
|
|
|
|
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", &body)
|
|
createReq.Header.Set("Content-Type", writer.FormDataContentType())
|
|
createReq.RemoteAddr = "127.0.0.1:5058"
|
|
createRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(createRec, createReq)
|
|
if createRec.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, want %d", createRec.Code, http.StatusCreated)
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode create payload error = %v", err)
|
|
}
|
|
id, _ := payload["id"].(string)
|
|
if id == "" {
|
|
t.Fatalf("create payload missing id")
|
|
}
|
|
|
|
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
|
|
rawReq.RemoteAddr = "127.0.0.1:5059"
|
|
rawRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rawRec, rawReq)
|
|
if rawRec.Code != http.StatusOK {
|
|
t.Fatalf("raw status = %d, want %d", rawRec.Code, http.StatusOK)
|
|
}
|
|
if got := rawRec.Body.String(); got != "uploaded file body" {
|
|
t.Fatalf("raw body = %q, want %q", got, "uploaded file body")
|
|
}
|
|
|
|
disposition := rawRec.Header().Get("Content-Disposition")
|
|
if disposition == "" {
|
|
t.Fatalf("expected Content-Disposition header")
|
|
}
|
|
_, params, err := mime.ParseMediaType(disposition)
|
|
if err != nil {
|
|
t.Fatalf("ParseMediaType() error = %v", err)
|
|
}
|
|
if got := params["filename"]; got != "example upload.txt" {
|
|
t.Fatalf("filename = %q, want %q", got, "example upload.txt")
|
|
}
|
|
}
|
|
|
|
func TestCreateScratchMultipartRejectsFileAndContent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestHandler(t, testServerOptions{
|
|
maxUploadBytes: 1024,
|
|
defaultTTL: time.Hour,
|
|
rateLimitEnable: false,
|
|
})
|
|
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
file, err := writer.CreateFormFile("file", "one.txt")
|
|
if err != nil {
|
|
t.Fatalf("CreateFormFile() error = %v", err)
|
|
}
|
|
if _, err := file.Write([]byte("file content")); err != nil {
|
|
t.Fatalf("write file error = %v", err)
|
|
}
|
|
if err := writer.WriteField("content", "extra content"); err != nil {
|
|
t.Fatalf("WriteField() error = %v", err)
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
t.Fatalf("multipart close error = %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/scratch", &body)
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
req.RemoteAddr = "127.0.0.1:5053"
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
func TestCreateScratchMultipartRequiresFileOrContent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestHandler(t, testServerOptions{
|
|
maxUploadBytes: 1024,
|
|
defaultTTL: time.Hour,
|
|
rateLimitEnable: false,
|
|
})
|
|
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
if err := writer.Close(); err != nil {
|
|
t.Fatalf("multipart close error = %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/scratch", &body)
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
req.RemoteAddr = "127.0.0.1:5054"
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
func TestGetScratchNotFoundReturnsGone(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestHandler(t, testServerOptions{
|
|
maxUploadBytes: 1024,
|
|
defaultTTL: time.Hour,
|
|
rateLimitEnable: false,
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/scratch/does-not-exist", nil)
|
|
req.RemoteAddr = "127.0.0.1:5055"
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusGone {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusGone)
|
|
}
|
|
}
|
|
|
|
func TestRawScratchDefaultsContentType(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler, store := newTestHandlerAndStore(t, testServerOptions{
|
|
maxUploadBytes: 1024,
|
|
defaultTTL: time.Hour,
|
|
rateLimitEnable: false,
|
|
})
|
|
|
|
meta, err := store.Create(t.Context(), strings.NewReader("no content type"), "", time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("store.Create() error = %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/raw/"+meta.ID, nil)
|
|
req.RemoteAddr = "127.0.0.1:5056"
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
if got := rec.Header().Get("Content-Type"); got != "application/octet-stream" {
|
|
t.Fatalf("Content-Type = %q, want %q", got, "application/octet-stream")
|
|
}
|
|
}
|
|
|
|
func TestRawScratchSupportsRangeRequests(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestHandler(t, testServerOptions{
|
|
maxUploadBytes: 1024,
|
|
defaultTTL: time.Hour,
|
|
rateLimitEnable: false,
|
|
})
|
|
|
|
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString("hello scratch"))
|
|
createReq.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
|
createReq.RemoteAddr = "127.0.0.1:5061"
|
|
createRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(createRec, createReq)
|
|
if createRec.Code != http.StatusCreated {
|
|
t.Fatalf("create status = %d, want %d", createRec.Code, http.StatusCreated)
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode create payload error = %v", err)
|
|
}
|
|
id, _ := payload["id"].(string)
|
|
if id == "" {
|
|
t.Fatalf("create payload missing id")
|
|
}
|
|
|
|
rangeReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
|
|
rangeReq.Header.Set("Range", "bytes=0-4")
|
|
rangeReq.RemoteAddr = "127.0.0.1:5062"
|
|
rangeRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rangeRec, rangeReq)
|
|
if rangeRec.Code != http.StatusPartialContent {
|
|
t.Fatalf("range status = %d, want %d", rangeRec.Code, http.StatusPartialContent)
|
|
}
|
|
if got := rangeRec.Body.String(); got != "hello" {
|
|
t.Fatalf("range body = %q, want %q", got, "hello")
|
|
}
|
|
if got := rangeRec.Header().Get("Content-Range"); got != "bytes 0-4/13" {
|
|
t.Fatalf("Content-Range = %q, want %q", got, "bytes 0-4/13")
|
|
}
|
|
}
|
|
|
|
func TestRawScratchRejectsUnsatisfiableRange(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestHandler(t, testServerOptions{
|
|
maxUploadBytes: 1024,
|
|
defaultTTL: time.Hour,
|
|
rateLimitEnable: false,
|
|
})
|
|
|
|
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString("hello scratch"))
|
|
createReq.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
|
createReq.RemoteAddr = "127.0.0.1:5063"
|
|
createRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(createRec, createReq)
|
|
if createRec.Code != http.StatusCreated {
|
|
t.Fatalf("create status = %d, want %d", createRec.Code, http.StatusCreated)
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode create payload error = %v", err)
|
|
}
|
|
id, _ := payload["id"].(string)
|
|
if id == "" {
|
|
t.Fatalf("create payload missing id")
|
|
}
|
|
|
|
rangeReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
|
|
rangeReq.Header.Set("Range", "bytes=999-1000")
|
|
rangeReq.RemoteAddr = "127.0.0.1:5064"
|
|
rangeRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rangeRec, rangeReq)
|
|
if rangeRec.Code != http.StatusRequestedRangeNotSatisfiable {
|
|
t.Fatalf("range status = %d, want %d", rangeRec.Code, http.StatusRequestedRangeNotSatisfiable)
|
|
}
|
|
if got := rangeRec.Header().Get("Content-Range"); got != "bytes */13" {
|
|
t.Fatalf("Content-Range = %q, want %q", got, "bytes */13")
|
|
}
|
|
}
|
|
|
|
func TestViewUploadAndIndexRender(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler, store := newTestHandlerAndStore(t, testServerOptions{
|
|
maxUploadBytes: 10 * 1024 * 1024,
|
|
defaultTTL: time.Hour,
|
|
rateLimitEnable: false,
|
|
})
|
|
|
|
meta, err := store.Create(t.Context(), strings.NewReader("hello view"), "text/plain; charset=utf-8", time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("store.Create() error = %v", err)
|
|
}
|
|
|
|
indexReq := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
indexRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(indexRec, indexReq)
|
|
if indexRec.Code != http.StatusOK {
|
|
t.Fatalf("index status = %d, want %d", indexRec.Code, http.StatusOK)
|
|
}
|
|
if !strings.Contains(indexRec.Body.String(), `id="app"`) {
|
|
t.Fatalf("expected index page to include SPA mount point")
|
|
}
|
|
if !strings.Contains(indexRec.Body.String(), `type="module"`) {
|
|
t.Fatalf("expected index page to load frontend bundle")
|
|
}
|
|
|
|
uploadReq := httptest.NewRequest(http.MethodGet, "/u", nil)
|
|
uploadRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(uploadRec, uploadReq)
|
|
if uploadRec.Code != http.StatusOK {
|
|
t.Fatalf("upload status = %d, want %d", uploadRec.Code, http.StatusOK)
|
|
}
|
|
if !strings.Contains(uploadRec.Body.String(), `id="app"`) {
|
|
t.Fatalf("expected upload page to include SPA mount point")
|
|
}
|
|
|
|
viewReq := httptest.NewRequest(http.MethodGet, "/s/"+meta.ID, nil)
|
|
viewRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(viewRec, viewReq)
|
|
if viewRec.Code != http.StatusOK {
|
|
t.Fatalf("view status = %d, want %d", viewRec.Code, http.StatusOK)
|
|
}
|
|
if !strings.Contains(viewRec.Body.String(), `id="app"`) {
|
|
t.Fatalf("expected scratch route to render SPA shell")
|
|
}
|
|
}
|
|
|
|
func TestViewAndRawScratchExpired(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler, store := newTestHandlerAndStore(t, testServerOptions{
|
|
maxUploadBytes: 1024,
|
|
defaultTTL: time.Hour,
|
|
rateLimitEnable: false,
|
|
})
|
|
meta, err := store.Create(t.Context(), strings.NewReader("expired body"), "text/plain", -time.Second)
|
|
if err != nil {
|
|
t.Fatalf("store.Create() error = %v", err)
|
|
}
|
|
|
|
viewReq := httptest.NewRequest(http.MethodGet, "/s/"+meta.ID, nil)
|
|
viewRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(viewRec, viewReq)
|
|
if viewRec.Code != http.StatusGone {
|
|
t.Fatalf("view status = %d, want %d", viewRec.Code, http.StatusGone)
|
|
}
|
|
|
|
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+meta.ID, nil)
|
|
rawRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rawRec, rawReq)
|
|
if rawRec.Code != http.StatusGone {
|
|
t.Fatalf("raw status after lazy delete = %d, want %d", rawRec.Code, http.StatusGone)
|
|
}
|
|
}
|
|
|
|
func TestRawScratchExpiredReturnsStatusOnly(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler, store := newTestHandlerAndStore(t, testServerOptions{
|
|
maxUploadBytes: 1024,
|
|
defaultTTL: time.Hour,
|
|
rateLimitEnable: false,
|
|
})
|
|
meta, err := store.Create(t.Context(), strings.NewReader("expired body"), "text/plain", -time.Second)
|
|
if err != nil {
|
|
t.Fatalf("store.Create() error = %v", err)
|
|
}
|
|
|
|
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+meta.ID, nil)
|
|
rawRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rawRec, rawReq)
|
|
if rawRec.Code != http.StatusGone {
|
|
t.Fatalf("raw status = %d, want %d", rawRec.Code, http.StatusGone)
|
|
}
|
|
if rawRec.Body.Len() != 0 {
|
|
t.Fatalf("expected empty response body, got %q", rawRec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestViewAndRawUnknownIDsReturnGone(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestHandler(t, testServerOptions{
|
|
maxUploadBytes: 1024,
|
|
defaultTTL: time.Hour,
|
|
rateLimitEnable: false,
|
|
})
|
|
|
|
viewReq := httptest.NewRequest(http.MethodGet, "/s/does-not-exist", nil)
|
|
viewRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(viewRec, viewReq)
|
|
if viewRec.Code != http.StatusGone {
|
|
t.Fatalf("view status = %d, want %d", viewRec.Code, http.StatusGone)
|
|
}
|
|
|
|
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/does-not-exist", nil)
|
|
rawRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rawRec, rawReq)
|
|
if rawRec.Code != http.StatusGone {
|
|
t.Fatalf("raw status = %d, want %d", rawRec.Code, http.StatusGone)
|
|
}
|
|
if rawRec.Body.Len() != 0 {
|
|
t.Fatalf("expected empty response body, got %q", rawRec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestNeverUploadedScratchIDsAlsoReturnGone(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestHandler(t, testServerOptions{
|
|
maxUploadBytes: 1024,
|
|
defaultTTL: time.Hour,
|
|
rateLimitEnable: false,
|
|
})
|
|
|
|
viewReq := httptest.NewRequest(http.MethodGet, "/s/never-uploaded-id", nil)
|
|
viewReq.RemoteAddr = "127.0.0.1:5095"
|
|
viewRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(viewRec, viewReq)
|
|
if viewRec.Code != http.StatusGone {
|
|
t.Fatalf("view-never-uploaded status = %d, want %d", viewRec.Code, http.StatusGone)
|
|
}
|
|
|
|
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/never-uploaded-id", nil)
|
|
rawReq.RemoteAddr = "127.0.0.1:5096"
|
|
rawRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rawRec, rawReq)
|
|
if rawRec.Code != http.StatusGone {
|
|
t.Fatalf("raw-never-uploaded status = %d, want %d", rawRec.Code, http.StatusGone)
|
|
}
|
|
if rawRec.Body.Len() != 0 {
|
|
t.Fatalf("expected empty raw body for never-uploaded id, got %q", rawRec.Body.String())
|
|
}
|
|
|
|
metaReq := httptest.NewRequest(http.MethodGet, "/api/scratch/never-uploaded-id", nil)
|
|
metaReq.RemoteAddr = "127.0.0.1:5097"
|
|
metaRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(metaRec, metaReq)
|
|
if metaRec.Code != http.StatusGone {
|
|
t.Fatalf("metadata-never-uploaded status = %d, want %d", metaRec.Code, http.StatusGone)
|
|
}
|
|
}
|
|
|
|
func TestCreateScratchRejectsMalformedMultipart(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestHandler(t, testServerOptions{
|
|
maxUploadBytes: 1024,
|
|
defaultTTL: time.Hour,
|
|
rateLimitEnable: false,
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/scratch", strings.NewReader("not-multipart"))
|
|
req.Header.Set("Content-Type", "multipart/form-data; boundary=missing")
|
|
req.RemoteAddr = "127.0.0.1:5057"
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
func TestConcurrentUploadsRemainConsistentAndReadable(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestHandler(t, testServerOptions{
|
|
maxUploadBytes: 1024 * 1024,
|
|
defaultTTL: time.Hour,
|
|
rateLimitEnable: false,
|
|
})
|
|
|
|
const uploads = 80
|
|
|
|
type createdScratch struct {
|
|
id string
|
|
body string
|
|
}
|
|
|
|
resultsCh := make(chan createdScratch, uploads)
|
|
errCh := make(chan error, uploads)
|
|
var wg sync.WaitGroup
|
|
|
|
for i := 0; i < uploads; i++ {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
|
|
body := fmt.Sprintf("parallel-upload-%03d-%s", i, strings.Repeat("x", i%17))
|
|
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString(body))
|
|
createReq.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
|
createReq.RemoteAddr = fmt.Sprintf("127.0.0.1:%d", 8200+i)
|
|
createRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(createRec, createReq)
|
|
if createRec.Code != http.StatusCreated {
|
|
errCh <- fmt.Errorf("create status = %d, want %d", createRec.Code, http.StatusCreated)
|
|
return
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil {
|
|
errCh <- fmt.Errorf("decode create payload error: %w", err)
|
|
return
|
|
}
|
|
id, _ := payload["id"].(string)
|
|
if id == "" {
|
|
errCh <- fmt.Errorf("create payload missing id")
|
|
return
|
|
}
|
|
|
|
metaReq := httptest.NewRequest(http.MethodGet, "/api/scratch/"+id, nil)
|
|
metaRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(metaRec, metaReq)
|
|
if metaRec.Code != http.StatusOK {
|
|
errCh <- fmt.Errorf("metadata status = %d, want %d", metaRec.Code, http.StatusOK)
|
|
return
|
|
}
|
|
|
|
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
|
|
rawRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rawRec, rawReq)
|
|
if rawRec.Code != http.StatusOK {
|
|
errCh <- fmt.Errorf("raw status = %d, want %d", rawRec.Code, http.StatusOK)
|
|
return
|
|
}
|
|
if got := rawRec.Body.String(); got != body {
|
|
errCh <- fmt.Errorf("raw body mismatch = %q, want %q", got, body)
|
|
return
|
|
}
|
|
|
|
resultsCh <- createdScratch{id: id, body: body}
|
|
}(i)
|
|
}
|
|
|
|
wg.Wait()
|
|
close(resultsCh)
|
|
close(errCh)
|
|
for err := range errCh {
|
|
t.Error(err)
|
|
}
|
|
|
|
seen := make(map[string]struct{}, uploads)
|
|
for result := range resultsCh {
|
|
if _, exists := seen[result.id]; exists {
|
|
t.Errorf("duplicate scratch id generated: %s", result.id)
|
|
continue
|
|
}
|
|
seen[result.id] = struct{}{}
|
|
}
|
|
if len(seen) != uploads {
|
|
t.Fatalf("unique IDs = %d, want %d", len(seen), uploads)
|
|
}
|
|
}
|
|
|
|
func TestParallelClientsCanReadSameScratchAcrossAllReadRoutes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestHandler(t, testServerOptions{
|
|
maxUploadBytes: 1024,
|
|
defaultTTL: time.Hour,
|
|
rateLimitEnable: false,
|
|
})
|
|
|
|
const body = "parallel readers"
|
|
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString(body))
|
|
createReq.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
|
createReq.RemoteAddr = "127.0.0.1:6010"
|
|
createRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(createRec, createReq)
|
|
if createRec.Code != http.StatusCreated {
|
|
t.Fatalf("create status = %d, want %d", createRec.Code, http.StatusCreated)
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode create payload error = %v", err)
|
|
}
|
|
id, _ := payload["id"].(string)
|
|
if id == "" {
|
|
t.Fatalf("create payload missing id")
|
|
}
|
|
|
|
paths := []string{
|
|
"/api/raw/" + id,
|
|
"/s/" + id,
|
|
"/api/scratch/" + id,
|
|
}
|
|
const requests = 120
|
|
|
|
errCh := make(chan error, requests)
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < requests; i++ {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
path := paths[i%len(paths)]
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
req.RemoteAddr = fmt.Sprintf("127.0.0.1:%d", 6100+i)
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
errCh <- fmt.Errorf("path %s status = %d, want %d", path, rec.Code, http.StatusOK)
|
|
return
|
|
}
|
|
|
|
switch {
|
|
case strings.HasPrefix(path, "/api/raw/"):
|
|
if got := rec.Body.String(); got != body {
|
|
errCh <- fmt.Errorf("raw body = %q, want %q", got, body)
|
|
}
|
|
case strings.HasPrefix(path, "/s/"):
|
|
if !strings.Contains(rec.Body.String(), `id="app"`) {
|
|
errCh <- fmt.Errorf("view body missing SPA marker")
|
|
}
|
|
case strings.HasPrefix(path, "/api/scratch/"):
|
|
var gotPayload map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &gotPayload); err != nil {
|
|
errCh <- fmt.Errorf("metadata decode error: %w", err)
|
|
return
|
|
}
|
|
if gotID, _ := gotPayload["id"].(string); gotID != id {
|
|
errCh <- fmt.Errorf("metadata id = %q, want %q", gotID, id)
|
|
}
|
|
}
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
close(errCh)
|
|
for err := range errCh {
|
|
t.Error(err)
|
|
}
|
|
}
|
|
|
|
func TestParallelClientsExpiredScratchReadsReturnGone(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestHandler(t, testServerOptions{
|
|
maxUploadBytes: 1024,
|
|
defaultTTL: 80 * time.Millisecond,
|
|
rateLimitEnable: false,
|
|
})
|
|
|
|
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString("expires soon"))
|
|
createReq.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
|
createReq.RemoteAddr = "127.0.0.1:7010"
|
|
createRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(createRec, createReq)
|
|
if createRec.Code != http.StatusCreated {
|
|
t.Fatalf("create status = %d, want %d", createRec.Code, http.StatusCreated)
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode create payload error = %v", err)
|
|
}
|
|
id, _ := payload["id"].(string)
|
|
if id == "" {
|
|
t.Fatalf("create payload missing id")
|
|
}
|
|
|
|
time.Sleep(130 * time.Millisecond)
|
|
|
|
paths := []string{
|
|
"/api/raw/" + id,
|
|
"/s/" + id,
|
|
"/api/scratch/" + id,
|
|
}
|
|
const requests = 90
|
|
|
|
errCh := make(chan error, requests)
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < requests; i++ {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
path := paths[i%len(paths)]
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
req.RemoteAddr = fmt.Sprintf("127.0.0.1:%d", 7100+i)
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusGone {
|
|
errCh <- fmt.Errorf("expired path %s status = %d, want %d", path, rec.Code, http.StatusGone)
|
|
return
|
|
}
|
|
|
|
if strings.HasPrefix(path, "/s/") && !strings.Contains(rec.Body.String(), `id="app"`) {
|
|
errCh <- fmt.Errorf("expired view body missing SPA marker")
|
|
}
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
close(errCh)
|
|
for err := range errCh {
|
|
t.Error(err)
|
|
}
|
|
}
|
|
|
|
func TestUnknownPathReturnsNotFoundPage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestHandler(t, testServerOptions{
|
|
maxUploadBytes: 1024,
|
|
defaultTTL: time.Hour,
|
|
rateLimitEnable: false,
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/this-path-does-not-exist", nil)
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), `id="app"`) {
|
|
t.Fatalf("unexpected not found body: %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
type testServerOptions struct {
|
|
maxUploadBytes int64
|
|
defaultTTL time.Duration
|
|
rateLimitEnable bool
|
|
}
|
|
|
|
func newTestHandler(t *testing.T, opts testServerOptions) http.Handler {
|
|
t.Helper()
|
|
|
|
handler, _ := newTestHandlerAndStore(t, opts)
|
|
return handler
|
|
}
|
|
|
|
func newTestHandlerAndStore(t *testing.T, opts testServerOptions) (http.Handler, *storage.FilesystemStore) {
|
|
t.Helper()
|
|
|
|
dataDir := filepath.Join(t.TempDir(), "data")
|
|
store, err := storage.NewFilesystemStore(dataDir)
|
|
if err != nil {
|
|
t.Fatalf("NewFilesystemStore() error = %v", err)
|
|
}
|
|
|
|
cfg := config.Config{
|
|
Server: config.ServerConfig{
|
|
ListenAddr: ":0",
|
|
},
|
|
Limits: config.LimitsConfig{
|
|
MaxUploadSizeBytes: opts.maxUploadBytes,
|
|
DefaultTTLDuration: opts.defaultTTL,
|
|
RawCacheMaxBytes: 64 * 1024 * 1024,
|
|
RawCacheMaxEntries: 32,
|
|
},
|
|
Storage: config.StorageConfig{
|
|
DataDir: dataDir,
|
|
},
|
|
Security: config.SecurityConfig{
|
|
RateLimit: config.RateLimitConfig{
|
|
Enabled: opts.rateLimitEnable,
|
|
RequestsPerMinute: 120,
|
|
Burst: 1,
|
|
},
|
|
},
|
|
}
|
|
|
|
srv := &Server{
|
|
cfg: cfg,
|
|
store: store,
|
|
logger: log.New(io.Discard, "", 0),
|
|
rawCache: newRawContentCache(cfg.Limits.RawCacheMaxEntries, cfg.Limits.RawCacheMaxBytes),
|
|
}
|
|
return srv.Routes(), store
|
|
}
|