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 TestReadRoutesAreRateLimitedWhenEnabled(t *testing.T) { t.Parallel() handler := newTestHandler(t, testServerOptions{ maxUploadBytes: 1024, defaultTTL: time.Hour, rateLimitEnable: true, }) 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:9111" 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") } firstRawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil) firstRawReq.RemoteAddr = "127.0.0.1:9111" firstRawRec := httptest.NewRecorder() handler.ServeHTTP(firstRawRec, firstRawReq) if firstRawRec.Code != http.StatusOK { t.Fatalf("first raw status = %d, want %d", firstRawRec.Code, http.StatusOK) } secondRawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil) secondRawReq.RemoteAddr = "127.0.0.1:9111" secondRawRec := httptest.NewRecorder() handler.ServeHTTP(secondRawRec, secondRawReq) if secondRawRec.Code != http.StatusTooManyRequests { t.Fatalf("second raw status = %d, want %d", secondRawRec.Code, http.StatusTooManyRequests) } } 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 }