Files
scratchbox/internal/http/handlers_unit_test.go
T
s1d3sw1ped_bot ed95ce8295
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
api/ui: Allow per-scratch TTL down to 1m
#4
2026-09-01 21:21:17 +00:00

255 lines
8.5 KiB
Go

package httpapi
import (
"errors"
"io"
"log"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
"scratchbox/internal/config"
"scratchbox/internal/storage"
)
func TestMultipartFileCount(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodPost, "/api/scratch", nil)
if got := multipartFileCount(req); got != 0 {
t.Fatalf("multipartFileCount() = %d, want 0", got)
}
}
func TestWriteCreateErrorBranches(t *testing.T) {
t.Parallel()
s := &Server{}
rec1 := httptest.NewRecorder()
s.writeCreateError(rec1, &http.MaxBytesError{Limit: 5})
if rec1.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("max bytes error status = %d, want %d", rec1.Code, http.StatusRequestEntityTooLarge)
}
rec2 := httptest.NewRecorder()
s.writeCreateError(rec2, errors.New("request body too large"))
if rec2.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("body too large string status = %d, want %d", rec2.Code, http.StatusRequestEntityTooLarge)
}
rec3 := httptest.NewRecorder()
s.writeCreateError(rec3, errors.New("other parse failure"))
if rec3.Code != http.StatusBadRequest {
t.Fatalf("generic error status = %d, want %d", rec3.Code, http.StatusBadRequest)
}
}
func TestNewServerInitializes(t *testing.T) {
tmp := t.TempDir()
store, err := storage.NewFilesystemStore(filepath.Join(tmp, "data"), testStorageKey)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
cfg := config.Config{
Limits: config.LimitsConfig{MaxUploadSizeBytes: 1024},
Security: config.SecurityConfig{
RateLimitUI: config.RateLimitConfig{Enabled: false, RequestsPerMinute: 30, Burst: 10},
RateLimitAPIRead: config.RateLimitConfig{Enabled: false, RequestsPerMinute: 30, Burst: 10},
RateLimitAPIWrite: config.RateLimitConfig{Enabled: false, RequestsPerMinute: 30, Burst: 10},
HSTSEnabled: true,
},
}
srv, err := NewServer(cfg, store, log.New(io.Discard, "", 0), log.New(io.Discard, "", 0))
if err != nil {
t.Fatalf("NewServer() error = %v", err)
}
if srv == nil {
t.Fatalf("expected initialized server")
}
}
func TestGetUIConfig(t *testing.T) {
t.Parallel()
s := &Server{
cfg: config.Config{
Limits: config.LimitsConfig{
MaxUploadSizeBytes: 2048,
},
},
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
s.getUIConfig(rec, req)
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`, `"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()
s := &Server{
cfg: config.Config{
Limits: config.LimitsConfig{
MaxUploadSizeBytes: 2048,
},
Security: config.SecurityConfig{
AllowedPrefixes: []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/8"),
},
},
},
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
s.getUIConfig(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if got := rec.Body.String(); got == "" || !containsAll(got, `"upload_allowed":false`) {
t.Fatalf("unexpected body: %q", got)
}
}
func TestWriteCreateErrorBranchesWithConfig(t *testing.T) {
tmp := t.TempDir()
store, err := storage.NewFilesystemStore(filepath.Join(tmp, "data"), testStorageKey)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
cfg := config.Config{
Limits: config.LimitsConfig{MaxUploadSizeBytes: 1024},
Security: config.SecurityConfig{
RateLimitUI: config.RateLimitConfig{Enabled: false, RequestsPerMinute: 30, Burst: 10},
RateLimitAPIRead: config.RateLimitConfig{Enabled: false, RequestsPerMinute: 30, Burst: 10},
RateLimitAPIWrite: config.RateLimitConfig{Enabled: false, RequestsPerMinute: 30, Burst: 10},
HSTSEnabled: true,
},
}
if _, err := NewServer(cfg, store, log.New(io.Discard, "", 0), log.New(io.Discard, "", 0)); err != nil {
t.Fatalf("NewServer() error = %v", err)
}
}
func containsAll(haystack string, needles ...string) bool {
for _, needle := range needles {
if !strings.Contains(haystack, needle) {
return false
}
}
return true
}
// TestInjectMetaEscapesSpecialChars exercises the server-side meta injection
// (used for /s/{id} and 410 pages) with filenames/titles containing HTML special
// chars. This provides basic coverage/golden-like check for the replace+escape
// logic against the exact literals in the committed static shell (addresses
// fragility note without changing to full template).
func TestInjectMetaEscapesSpecialChars(t *testing.T) {
t.Parallel()
// Minimal shell containing exactly the replace targets used by injectMeta.
// (Real index.html from Vite is larger but uses these literal strings.)
shell := []byte(`<!doctype html>
<html><head>
<title>Scratchbox</title>
<meta property="og:title" content="Scratchbox" />
<meta name="twitter:title" content="Scratchbox" />
<meta name="description" content="Minimal temporary file sharing. Upload once, share the link, auto-expires." />
<meta property="og:description" content="Minimal temporary file sharing. Upload once, share the link, auto-expires." />
<meta name="twitter:description" content="Minimal temporary file sharing. Upload once, share the link, auto-expires." />
</head><body></body></html>`)
tricky := `report & "notes" <2026> 'foo'`
gotBytes := injectMeta(shell, tricky, tricky, tricky)
got := string(gotBytes)
// Must have escaped the specials.
if !strings.Contains(got, `&amp;`) || !strings.Contains(got, `&lt;`) || !strings.Contains(got, `&gt;`) || !strings.Contains(got, `&quot;`) {
t.Fatalf("injectMeta did not escape specials in output: %q", got)
}
// Title etc updated (escaped form present).
if !strings.Contains(got, `report &amp; &quot;notes&quot; &lt;2026&gt;`) {
t.Fatalf("injectMeta title not updated or badly escaped: %q", got)
}
// Original static should be gone (count=1 replaces).
if strings.Contains(got, `<title>Scratchbox</title>`) {
t.Fatalf("static title not replaced")
}
}