package httpapi import ( "bytes" "encoding/json" "html/template" "io" "log" "mime/multipart" "net/http" "net/http/httptest" "path/filepath" "runtime" "strings" "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, "/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 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()) } } 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 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 TestGetScratchNotFound(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.StatusNotFound { t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound) } } 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, "/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 TestViewScratchAndIndexRender(t *testing.T) { t.Parallel() handler, store := newTemplateHandlerAndStore(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) } 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(), "hello view") { t.Fatalf("expected view body to include scratch content") } } func TestViewAndRawScratchExpired(t *testing.T) { t.Parallel() handler, store := newTemplateHandlerAndStore(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, "/raw/"+meta.ID, nil) rawRec := httptest.NewRecorder() handler.ServeHTTP(rawRec, rawReq) if rawRec.Code != http.StatusNotFound { t.Fatalf("raw status after lazy delete = %d, want %d", rawRec.Code, http.StatusNotFound) } } 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) } } 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, }, 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), templates: nil, } return srv.Routes(), store } func newTemplateHandlerAndStore(t *testing.T, opts testServerOptions) (http.Handler, *storage.FilesystemStore) { t.Helper() handler, store := newTestHandlerAndStore(t, opts) _ = handler // keep shared setup path _, filePath, _, ok := runtime.Caller(0) if !ok { t.Fatal("runtime.Caller failed") } repoRoot := filepath.Clean(filepath.Join(filepath.Dir(filePath), "..", "..")) tmpl, err := template.ParseFiles( filepath.Join(repoRoot, "web", "templates", "index.html"), filepath.Join(repoRoot, "web", "templates", "view.html"), ) if err != nil { t.Fatalf("ParseFiles() error = %v", err) } cfg := config.Config{ Server: config.ServerConfig{ ListenAddr: ":0", }, Limits: config.LimitsConfig{ MaxUploadSizeBytes: opts.maxUploadBytes, DefaultTTLDuration: opts.defaultTTL, }, Storage: config.StorageConfig{ DataDir: filepath.Join(t.TempDir(), "unused"), }, 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), templates: tmpl, } return srv.Routes(), store }