initial commit

This commit is contained in:
2026-05-29 22:39:50 -05:00
commit 47c767f9e8
23 changed files with 3361 additions and 0 deletions
+454
View File
@@ -0,0 +1,454 @@
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
}