api/ui: Allow per-scratch TTL down to 1m
Format / gofmt (push) Successful in 26s
Format / gofmt (pull_request) Successful in 26s
CI / Build (push) Successful in 33s
CI / Build (pull_request) Successful in 33s
CI / Go Tests (push) Successful in 35s
CI / Go Tests (pull_request) Successful in 35s
Format / gofmt (push) Successful in 26s
Format / gofmt (pull_request) Successful in 26s
CI / Build (push) Successful in 33s
CI / Build (pull_request) Successful in 33s
CI / Go Tests (push) Successful in 35s
CI / Go Tests (pull_request) Successful in 35s
#4
This commit is contained in:
@@ -106,6 +106,7 @@ func (s *Server) getUIConfig(w http.ResponseWriter, r *http.Request) {
|
||||
"max_upload_size_bytes": s.cfg.Limits.MaxUploadSizeBytes,
|
||||
"upload_allowed": s.isUploadAllowedForRequest(r),
|
||||
"default_ttl": s.cfg.Limits.DefaultTTL,
|
||||
"max_ttl": config.MaxScratchTTL.String(),
|
||||
}
|
||||
writeJSON(w, http.StatusOK, payload)
|
||||
}
|
||||
@@ -184,8 +185,9 @@ func (s *Server) createScratch(w http.ResponseWriter, r *http.Request) {
|
||||
var reader io.Reader
|
||||
originalName := ""
|
||||
contentType := strings.TrimSpace(r.Header.Get("Content-Type"))
|
||||
multipart := strings.HasPrefix(contentType, "multipart/form-data")
|
||||
|
||||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||||
if multipart {
|
||||
if err := r.ParseMultipartForm(multipartMaxMemory); err != nil {
|
||||
s.writeCreateError(w, err)
|
||||
return
|
||||
@@ -228,7 +230,13 @@ func (s *Server) createScratch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
meta, err := s.store.CreateWithOriginalName(reader, contentType, originalName, s.cfg.Limits.DefaultTTLDuration)
|
||||
ttl, err := s.resolveTTL(r, multipart)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
meta, err := s.store.CreateWithOriginalName(reader, contentType, originalName, ttl)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "request body too large") {
|
||||
http.Error(w, s.maxUploadSizeError(), http.StatusRequestEntityTooLarge)
|
||||
@@ -364,6 +372,33 @@ func (s *Server) rawScratch(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeContent(w, r, meta.ID, meta.CreatedAt, bytes.NewReader(content))
|
||||
}
|
||||
|
||||
// resolveTTL returns the per-scratch TTL for POST /api/scratch.
|
||||
// Multipart uploads read form field "ttl"; raw-body uploads read query "ttl".
|
||||
// Omitted or empty values use limits.default_ttl. Invalid or out-of-range
|
||||
// values (below 1m, above MaxScratchTTL, or non-duration) return an error.
|
||||
// Infinite TTL is not allowed.
|
||||
func (s *Server) resolveTTL(r *http.Request, multipart bool) (time.Duration, error) {
|
||||
raw := ""
|
||||
if multipart {
|
||||
raw = strings.TrimSpace(r.FormValue("ttl"))
|
||||
} else {
|
||||
raw = strings.TrimSpace(r.URL.Query().Get("ttl"))
|
||||
}
|
||||
if raw == "" {
|
||||
return s.cfg.Limits.DefaultTTLDuration, nil
|
||||
}
|
||||
parsed, err := time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
return 0, errInvalidScratchTTL
|
||||
}
|
||||
if parsed < config.MinScratchTTL || parsed > config.MaxScratchTTL {
|
||||
return 0, errInvalidScratchTTL
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
var errInvalidScratchTTL = fmt.Errorf("ttl must be a Go duration between %s and %s", config.MinScratchTTL, config.MaxScratchTTL)
|
||||
|
||||
func (s *Server) writeCreateError(w http.ResponseWriter, err error) {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) || strings.Contains(err.Error(), "request body too large") {
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCreateScratchTTLQueryOmitUsesDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestHandler(t, testServerOptions{
|
||||
maxUploadBytes: 1024,
|
||||
defaultTTL: 15 * time.Minute,
|
||||
rateLimitEnable: false,
|
||||
})
|
||||
|
||||
before := time.Now().UTC()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString("omit-ttl"))
|
||||
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
req.RemoteAddr = "127.0.0.1:8101"
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want %d body=%q", rec.Code, http.StatusCreated, rec.Body.String())
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
expiresAt, err := time.Parse(time.RFC3339Nano, payload["expires_at"].(string))
|
||||
if err != nil {
|
||||
// JSON may marshal as RFC3339
|
||||
expiresAt, err = time.Parse(time.RFC3339, payload["expires_at"].(string))
|
||||
if err != nil {
|
||||
t.Fatalf("parse expires_at %v (%T): %v", payload["expires_at"], payload["expires_at"], err)
|
||||
}
|
||||
}
|
||||
delta := expiresAt.Sub(before)
|
||||
if delta < 14*time.Minute || delta > 16*time.Minute {
|
||||
t.Fatalf("expires delta = %s, want ~15m", delta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateScratchTTLQueryMinMaxAndInvalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestHandler(t, testServerOptions{
|
||||
maxUploadBytes: 1024,
|
||||
defaultTTL: 15 * time.Minute,
|
||||
rateLimitEnable: false,
|
||||
})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
ttl string
|
||||
wantStatus int
|
||||
wantDelta time.Duration
|
||||
deltaSlack time.Duration
|
||||
}{
|
||||
{name: "min", ttl: "1m", wantStatus: http.StatusCreated, wantDelta: time.Minute, deltaSlack: 15 * time.Second},
|
||||
{name: "max", ttl: "24h", wantStatus: http.StatusCreated, wantDelta: 24 * time.Hour, deltaSlack: time.Minute},
|
||||
{name: "custom", ttl: "2h", wantStatus: http.StatusCreated, wantDelta: 2 * time.Hour, deltaSlack: 30 * time.Second},
|
||||
{name: "below-min", ttl: "30s", wantStatus: http.StatusBadRequest},
|
||||
{name: "above-max", ttl: "25h", wantStatus: http.StatusBadRequest},
|
||||
{name: "invalid", ttl: "not-a-duration", wantStatus: http.StatusBadRequest},
|
||||
{name: "zero", ttl: "0s", wantStatus: http.StatusBadRequest},
|
||||
{name: "negative", ttl: "-5m", wantStatus: http.StatusBadRequest},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
before := time.Now().UTC()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/scratch?ttl="+tc.ttl, bytes.NewBufferString("body-"+tc.name))
|
||||
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
req.RemoteAddr = "127.0.0.1:8200"
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != tc.wantStatus {
|
||||
t.Fatalf("status = %d, want %d body=%q", rec.Code, tc.wantStatus, rec.Body.String())
|
||||
}
|
||||
if tc.wantStatus != http.StatusCreated {
|
||||
if !strings.Contains(rec.Body.String(), "ttl") {
|
||||
t.Fatalf("expected ttl error message, got %q", rec.Body.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
expiresRaw, _ := payload["expires_at"].(string)
|
||||
expiresAt, err := time.Parse(time.RFC3339Nano, expiresRaw)
|
||||
if err != nil {
|
||||
expiresAt, err = time.Parse(time.RFC3339, expiresRaw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse expires_at %q: %v", expiresRaw, err)
|
||||
}
|
||||
}
|
||||
delta := expiresAt.Sub(before)
|
||||
if delta < tc.wantDelta-tc.deltaSlack || delta > tc.wantDelta+tc.deltaSlack {
|
||||
t.Fatalf("expires delta = %s, want ~%s", delta, tc.wantDelta)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateScratchTTLMultipartFormField(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestHandler(t, testServerOptions{
|
||||
maxUploadBytes: 1024,
|
||||
defaultTTL: 15 * time.Minute,
|
||||
rateLimitEnable: false,
|
||||
})
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
file, err := writer.CreateFormFile("file", "ttl.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFormFile: %v", err)
|
||||
}
|
||||
if _, err := file.Write([]byte("multipart-ttl")); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
if err := writer.WriteField("ttl", "5m"); err != nil {
|
||||
t.Fatalf("WriteField ttl: %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close writer: %v", err)
|
||||
}
|
||||
|
||||
before := time.Now().UTC()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/scratch", &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.RemoteAddr = "127.0.0.1:8301"
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want %d body=%q", rec.Code, http.StatusCreated, rec.Body.String())
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
expiresRaw, _ := payload["expires_at"].(string)
|
||||
expiresAt, err := time.Parse(time.RFC3339Nano, expiresRaw)
|
||||
if err != nil {
|
||||
expiresAt, err = time.Parse(time.RFC3339, expiresRaw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse expires_at %q: %v", expiresRaw, err)
|
||||
}
|
||||
}
|
||||
delta := expiresAt.Sub(before)
|
||||
if delta < 4*time.Minute || delta > 6*time.Minute {
|
||||
t.Fatalf("expires delta = %s, want ~5m", delta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateScratchTTLMultipartInvalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestHandler(t, testServerOptions{
|
||||
maxUploadBytes: 1024,
|
||||
defaultTTL: 15 * time.Minute,
|
||||
rateLimitEnable: false,
|
||||
})
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
file, _ := writer.CreateFormFile("file", "bad-ttl.txt")
|
||||
_, _ = file.Write([]byte("x"))
|
||||
_ = writer.WriteField("ttl", "forever")
|
||||
_ = writer.Close()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/scratch", &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.RemoteAddr = "127.0.0.1:8302"
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d body=%q", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUIConfigExposesMaxTTL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestHandler(t, testServerOptions{
|
||||
maxUploadBytes: 2048,
|
||||
defaultTTL: 15 * time.Minute,
|
||||
rateLimitEnable: false,
|
||||
})
|
||||
|
||||
// DefaultTTL string is empty in test helper; set via direct server call already covered.
|
||||
// Hit routed /api/config and ensure max_ttl is present.
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
||||
req.RemoteAddr = "127.0.0.1:8401"
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, `"max_ttl"`) {
|
||||
t.Fatalf("expected max_ttl in body: %q", body)
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,11 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"scratchbox/internal/config"
|
||||
"scratchbox/internal/storage"
|
||||
@@ -88,11 +90,76 @@ func TestGetUIConfig(t *testing.T) {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
if got := rec.Body.String(); got == "" || !containsAll(got, "max_upload_size_bytes", "2048", `"upload_allowed":true`) {
|
||||
if got := rec.Body.String(); got == "" || !containsAll(got, "max_upload_size_bytes", "2048", `"upload_allowed":true`, `"max_ttl":"`+config.MaxScratchTTL.String()+`"`, "default_ttl") {
|
||||
t.Fatalf("unexpected body: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTTL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := &Server{
|
||||
cfg: config.Config{
|
||||
Limits: config.LimitsConfig{
|
||||
DefaultTTLDuration: 15 * time.Minute,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
formTTL string
|
||||
multipart bool
|
||||
want time.Duration
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "omit raw uses default", url: "/api/scratch", want: 15 * time.Minute},
|
||||
{name: "empty query uses default", url: "/api/scratch?ttl=", want: 15 * time.Minute},
|
||||
{name: "empty form uses default", url: "/api/scratch", multipart: true, formTTL: " ", want: 15 * time.Minute},
|
||||
{name: "query min", url: "/api/scratch?ttl=1m", want: time.Minute},
|
||||
{name: "query max", url: "/api/scratch?ttl=24h", want: config.MaxScratchTTL},
|
||||
{name: "query default value", url: "/api/scratch?ttl=15m", want: 15 * time.Minute},
|
||||
{name: "form min", url: "/api/scratch", multipart: true, formTTL: "1m", want: time.Minute},
|
||||
{name: "form max", url: "/api/scratch", multipart: true, formTTL: "24h", want: config.MaxScratchTTL},
|
||||
{name: "form default value", url: "/api/scratch", multipart: true, formTTL: "15m", want: 15 * time.Minute},
|
||||
{name: "invalid duration", url: "/api/scratch?ttl=nope", wantErr: true},
|
||||
{name: "below min", url: "/api/scratch?ttl=59s", wantErr: true},
|
||||
{name: "above max", url: "/api/scratch?ttl=24h1s", wantErr: true},
|
||||
{name: "zero", url: "/api/scratch?ttl=0", wantErr: true},
|
||||
{name: "negative", url: "/api/scratch?ttl=-1m", wantErr: true},
|
||||
{name: "form invalid", url: "/api/scratch", multipart: true, formTTL: "forever", wantErr: true},
|
||||
{name: "form below min", url: "/api/scratch", multipart: true, formTTL: "30s", wantErr: true},
|
||||
{name: "form above max", url: "/api/scratch", multipart: true, formTTL: "25h", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
req := httptest.NewRequest(http.MethodPost, tc.url, nil)
|
||||
if tc.multipart {
|
||||
req.Form = url.Values{}
|
||||
if tc.formTTL != "" {
|
||||
req.Form.Set("ttl", tc.formTTL)
|
||||
}
|
||||
}
|
||||
got, err := s.resolveTTL(req, tc.multipart)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("resolveTTL() error = nil, want error (got %s)", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("resolveTTL() error = %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("resolveTTL() = %s, want %s", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUIConfigUploadDenied(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user