Files
scratchbox/cmd/scratchbox/main_test.go
T
s1d3sw1ped ad4355df17
CI / Go Tests (push) Successful in 15s
CI / Build (push) Successful in 9s
Format / gofmt (push) Successful in 7s
Release Artifacts / Validate release tag (push) Successful in 2s
Release Artifacts / Build and release executables (push) Successful in 15s
Release Artifacts / Build and release Docker image (push) Successful in 28s
Heavy security and file splitting
2026-06-01 20:15:28 -05:00

551 lines
15 KiB
Go

package main
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"io"
"log"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"gopkg.in/yaml.v3"
"scratchbox/internal/config"
httpapi "scratchbox/internal/http"
"scratchbox/internal/storage"
)
func repoRoot(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
return filepath.Clean(filepath.Join(filepath.Dir(file), "..", ".."))
}
func TestMainProcessSuccess(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfgPath := filepath.Join(tmp, "config.yaml")
cfg := `
server:
listen_addr: "127.0.0.1:0"
limits:
max_upload_size: "1MB"
default_ttl: "1h"
storage:
data_dir: "` + filepath.Join(tmp, "data") + `"
cleanup_interval: "10s"
security:
allowed_ips: []
trust_proxy_headers: false
rate_limit_ui:
enabled: true
requests_per_minute: 10
burst: 1
rate_limit_api_read:
enabled: true
requests_per_minute: 10
burst: 1
rate_limit_api_write:
enabled: true
requests_per_minute: 10
burst: 1
`
if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
cmd := exec.Command(os.Args[0], "-test.run=TestMainHelperProcess")
cmd.Dir = repoRoot(t)
cmd.Env = append(os.Environ(),
"GO_WANT_MAIN_HELPER=1",
"MAIN_HELPER_MODE=success",
"MAIN_HELPER_CONFIG="+cfgPath,
)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("helper process failed: %v, output=%s", err, string(out))
}
}
func TestMainProcessBadConfigFails(t *testing.T) {
t.Parallel()
cmd := exec.Command(os.Args[0], "-test.run=TestMainHelperProcess")
cmd.Dir = repoRoot(t)
cmd.Env = append(os.Environ(),
"GO_WANT_MAIN_HELPER=1",
"MAIN_HELPER_MODE=bad-config",
)
if err := cmd.Run(); err == nil {
t.Fatalf("expected helper process failure for bad config")
}
}
func TestMainProcessMissingConfigAutoGenerates(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfgPath := filepath.Join(tmp, "nested", "config.yaml")
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=TestMainHelperProcess")
cmd.Dir = repoRoot(t)
cmd.Env = append(os.Environ(),
"GO_WANT_MAIN_HELPER=1",
"MAIN_HELPER_MODE=missing-config-autogen",
"MAIN_HELPER_CONFIG="+cfgPath,
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("helper process failed: %v, output=%s", err, string(out))
}
if !strings.Contains(string(out), "Review and adjust it, then re-run the same command.") {
t.Fatalf("helper output missing expected guidance, output=%q", string(out))
}
raw, err := os.ReadFile(cfgPath)
if err != nil {
t.Fatalf("ReadFile(generated config) error = %v", err)
}
if !strings.Contains(string(raw), "server:") {
t.Fatalf("generated config missing expected section")
}
}
func TestMainProcessEnvSuccess(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cmd := exec.Command(os.Args[0], "-test.run=TestMainHelperProcess")
cmd.Dir = repoRoot(t)
cmd.Env = append(os.Environ(),
"GO_WANT_MAIN_HELPER=1",
"MAIN_HELPER_MODE=env-success",
"SCRATCHBOX_SERVER_LISTEN_ADDR=127.0.0.1:0",
"SCRATCHBOX_STORAGE_DATA_DIR="+filepath.Join(tmp, "data"),
"SCRATCHBOX_SECURITY_ALLOWED_IPS=",
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("helper process failed: %v, output=%s", err, string(out))
}
if !strings.Contains(string(out), "[scratchbox] --env startup variables:") {
t.Fatalf("helper output missing env startup header, output=%q", string(out))
}
if !strings.Contains(string(out), "SCRATCHBOX_SERVER_LISTEN_ADDR=127.0.0.1:0") {
t.Fatalf("helper output missing expected env var, output=%q", string(out))
}
}
func TestMainProcessEnvWithoutScratchboxVarsFails(t *testing.T) {
t.Parallel()
cmd := exec.Command(os.Args[0], "-test.run=TestMainHelperProcess")
cmd.Dir = repoRoot(t)
cmd.Env = []string{
"GO_WANT_MAIN_HELPER=1",
"MAIN_HELPER_MODE=env-no-vars",
}
if err := cmd.Run(); err == nil {
t.Fatalf("expected helper process failure when --env has no SCRATCHBOX_* vars")
}
}
func TestServerConfigAndEnvMutuallyExclusive(t *testing.T) {
t.Parallel()
cmd := newRootCmd()
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"server", "--config", "config.yaml", "--env"})
err := cmd.Execute()
if err == nil {
t.Fatalf("expected error when both --config and --env are set")
}
if !strings.Contains(err.Error(), "none of the others can be") {
t.Fatalf("error = %q, want mutually exclusive flags error", err.Error())
}
}
func TestMainProcessStorageInitFails(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfgPath := filepath.Join(tmp, "config.yaml")
dataFile := filepath.Join(tmp, "data-file")
if err := os.WriteFile(dataFile, []byte("x"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
cfg := `
server:
listen_addr: "127.0.0.1:0"
limits:
max_upload_size: "1MB"
default_ttl: "1h"
storage:
data_dir: "` + dataFile + `"
cleanup_interval: "10s"
security:
allowed_ips: []
trust_proxy_headers: false
rate_limit_ui:
enabled: true
requests_per_minute: 10
burst: 1
rate_limit_api_read:
enabled: true
requests_per_minute: 10
burst: 1
rate_limit_api_write:
enabled: true
requests_per_minute: 10
burst: 1
`
if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
cmd := exec.Command(os.Args[0], "-test.run=TestMainHelperProcess")
cmd.Dir = repoRoot(t)
cmd.Env = append(os.Environ(),
"GO_WANT_MAIN_HELPER=1",
"MAIN_HELPER_MODE=storage-fail",
"MAIN_HELPER_CONFIG="+cfgPath,
)
if err := cmd.Run(); err == nil {
t.Fatalf("expected helper process failure for storage init")
}
}
func TestMainHelperProcess(t *testing.T) {
if os.Getenv("GO_WANT_MAIN_HELPER") != "1" {
return
}
mode := os.Getenv("MAIN_HELPER_MODE")
switch mode {
case "success":
cfgPath := os.Getenv("MAIN_HELPER_CONFIG")
if cfgPath == "" {
os.Exit(2)
}
go func() {
time.Sleep(120 * time.Millisecond)
proc, err := os.FindProcess(os.Getpid())
if err == nil {
_ = proc.Signal(os.Interrupt)
}
}()
os.Args = []string{"scratchbox", "server", "--config", cfgPath}
main()
os.Exit(0)
case "bad-config":
// Use a directory path so config init fails deterministically.
os.Args = []string{"scratchbox", "server", "--config", "/"}
main()
os.Exit(0)
case "missing-config-autogen":
cfgPath := os.Getenv("MAIN_HELPER_CONFIG")
if cfgPath == "" {
os.Exit(2)
}
os.Args = []string{"scratchbox", "server", "--config", cfgPath}
main()
os.Exit(0)
case "env-success":
go func() {
time.Sleep(120 * time.Millisecond)
proc, err := os.FindProcess(os.Getpid())
if err == nil {
_ = proc.Signal(os.Interrupt)
}
}()
os.Args = []string{"scratchbox", "server", "--env"}
main()
os.Exit(0)
case "env-no-vars":
os.Args = []string{"scratchbox", "server", "--env"}
main()
os.Exit(0)
case "storage-fail":
cfgPath := os.Getenv("MAIN_HELPER_CONFIG")
os.Args = []string{"scratchbox", "server", "--config", cfgPath}
main()
os.Exit(0)
default:
os.Exit(2)
}
}
func TestGenerateConfigCommandWritesDefaultYAML(t *testing.T) {
t.Parallel()
cmd := newRootCmd()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"generate-config"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if out.Len() == 0 {
t.Fatalf("generate-config output is empty")
}
output := out.String()
if !strings.Contains(output, "# HTTP listener settings.") {
t.Fatalf("generate-config output missing expected section comment")
}
if !strings.Contains(output, "# Maximum upload request body size. Supports B, KB, MB, GB and KiB, MiB, GiB (case-insensitive). Defaults to bytes when unit is omitted (example: 1048576).") {
t.Fatalf("generate-config output missing expected field comment")
}
var cfg config.Config
if err := yaml.Unmarshal([]byte(output), &cfg); err != nil {
t.Fatalf("yaml.Unmarshal() error = %v", err)
}
if cfg.Server.ListenAddr != ":8080" {
t.Fatalf("server.listen_addr = %q, want %q", cfg.Server.ListenAddr, ":8080")
}
if cfg.Limits.MaxUploadSize != "100MiB" {
t.Fatalf("limits.max_upload_size = %q, want %q", cfg.Limits.MaxUploadSize, "100MiB")
}
if cfg.Storage.DataDir != "./data" {
t.Fatalf("storage.data_dir = %q, want %q", cfg.Storage.DataDir, "./data")
}
if cfg.Security.RateLimitUI.RequestsPerMinute != 360 {
t.Fatalf("security.rate_limit_ui.requests_per_minute = %d, want %d", cfg.Security.RateLimitUI.RequestsPerMinute, 360)
}
if cfg.Security.RateLimitUI.Enabled {
t.Fatalf("security.rate_limit_ui.enabled = %v, want false", cfg.Security.RateLimitUI.Enabled)
}
if cfg.Security.RateLimitAPIRead.RequestsPerMinute != 300 {
t.Fatalf("security.rate_limit_api_read.requests_per_minute = %d, want %d", cfg.Security.RateLimitAPIRead.RequestsPerMinute, 300)
}
if cfg.Security.RateLimitAPIRead.Enabled {
t.Fatalf("security.rate_limit_api_read.enabled = %v, want false", cfg.Security.RateLimitAPIRead.Enabled)
}
if cfg.Security.RateLimitAPIWrite.RequestsPerMinute != 30 {
t.Fatalf("security.rate_limit_api_write.requests_per_minute = %d, want %d", cfg.Security.RateLimitAPIWrite.RequestsPerMinute, 30)
}
if !cfg.Security.RateLimitAPIWrite.Enabled {
t.Fatalf("security.rate_limit_api_write.enabled = %v, want true", cfg.Security.RateLimitAPIWrite.Enabled)
}
// Additional assertions act as golden checks against generate drift in defaults/comments/struct.
if cfg.Storage.CleanupInterval != "1m" {
t.Fatalf("storage.cleanup_interval = %q, want %q", cfg.Storage.CleanupInterval, "1m")
}
if !cfg.Security.HSTSEnabled {
t.Fatalf("security.hsts_enabled = %v, want true", cfg.Security.HSTSEnabled)
}
if cfg.Limits.RawCacheMaxEntries != 32 {
t.Fatalf("limits.raw_cache_max_entries = %d, want 32", cfg.Limits.RawCacheMaxEntries)
}
}
func TestGenerateConfigCommandRejectsUnexpectedArgs(t *testing.T) {
t.Parallel()
cmd := newRootCmd()
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"generate-config", "extra"})
if err := cmd.Execute(); err == nil {
t.Fatalf("expected error for unexpected args")
}
}
func TestGenerateKeyCommandWritesKey(t *testing.T) {
t.Parallel()
cmd := newRootCmd()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"generate-key"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
raw := strings.TrimSpace(out.String())
if raw == "" {
t.Fatalf("generate-key output is empty")
}
keyBytes, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
t.Fatalf("generate-key output is not valid base64: %v", err)
}
if len(keyBytes) != 32 {
t.Fatalf("decoded key length = %d, want 32", len(keyBytes))
}
}
func TestGenerateKeyCommandRejectsUnexpectedArgs(t *testing.T) {
t.Parallel()
cmd := newRootCmd()
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"generate-key", "extra"})
if err := cmd.Execute(); err == nil {
t.Fatalf("expected error for unexpected args")
}
}
func TestLiveServerEndToEndHTTP(t *testing.T) {
tmp := t.TempDir()
cfgPath := filepath.Join(tmp, "config.yaml")
cfgRaw := `
server:
listen_addr: "127.0.0.1:0"
limits:
max_upload_size: "1MB"
default_ttl: "1h"
storage:
data_dir: "` + filepath.Join(tmp, "data") + `"
cleanup_interval: "10s"
security:
allowed_ips: []
trust_proxy_headers: false
rate_limit_ui:
enabled: true
requests_per_minute: 30
burst: 10
rate_limit_api_read:
enabled: true
requests_per_minute: 30
burst: 10
rate_limit_api_write:
enabled: true
requests_per_minute: 30
burst: 10
`
if err := os.WriteFile(cfgPath, []byte(cfgRaw), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
if err := cfg.PrepareDataDir(); err != nil {
t.Fatalf("config.PrepareDataDir() error = %v", err)
}
logger := log.New(io.Discard, "", 0)
store, err := storage.NewFilesystemStore(cfg.Storage.DataDir, cfg.Storage.EncryptionKeyBytes)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
wd, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd() error = %v", err)
}
if err := os.Chdir(repoRoot(t)); err != nil {
t.Fatalf("Chdir() error = %v", err)
}
defer func() {
_ = os.Chdir(wd)
}()
srv, err := httpapi.NewServer(cfg, store, logger, logger)
if err != nil {
t.Fatalf("httpapi.NewServer() error = %v", err)
}
httpServer := &http.Server{
Addr: cfg.Server.ListenAddr,
Handler: srv.Routes(),
}
ln, err := net.Listen("tcp", cfg.Server.ListenAddr)
if err != nil {
t.Fatalf("net.Listen() error = %v", err)
}
defer ln.Close()
go func() {
_ = httpServer.Serve(ln)
}()
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = httpServer.Shutdown(ctx)
})
baseURL := "http://" + ln.Addr().String()
client := &http.Client{Timeout: 3 * time.Second}
createReq, err := http.NewRequest(http.MethodPost, baseURL+"/api/scratch", bytes.NewBufferString("hello from live server"))
if err != nil {
t.Fatalf("http.NewRequest(create) error = %v", err)
}
createReq.Header.Set("Content-Type", "text/plain; charset=utf-8")
createResp, err := client.Do(createReq)
if err != nil {
t.Fatalf("client.Do(create) error = %v", err)
}
defer createResp.Body.Close()
if createResp.StatusCode != http.StatusCreated {
b, _ := io.ReadAll(createResp.Body)
t.Fatalf("create status = %d, want %d, body=%q", createResp.StatusCode, http.StatusCreated, string(b))
}
var payload map[string]any
if err := json.NewDecoder(createResp.Body).Decode(&payload); err != nil {
t.Fatalf("decode create payload error = %v", err)
}
id, _ := payload["id"].(string)
if id == "" {
t.Fatalf("create payload missing id")
}
rawResp, err := client.Get(baseURL + "/api/raw/" + id)
if err != nil {
t.Fatalf("client.Get(raw) error = %v", err)
}
defer rawResp.Body.Close()
if rawResp.StatusCode != http.StatusOK {
t.Fatalf("raw status = %d, want %d", rawResp.StatusCode, http.StatusOK)
}
rawBody, err := io.ReadAll(rawResp.Body)
if err != nil {
t.Fatalf("ReadAll(raw) error = %v", err)
}
if got := string(rawBody); got != "hello from live server" {
t.Fatalf("raw body = %q, want %q", got, "hello from live server")
}
viewResp, err := client.Get(baseURL + "/s/" + id)
if err != nil {
t.Fatalf("client.Get(view) error = %v", err)
}
defer viewResp.Body.Close()
if viewResp.StatusCode != http.StatusOK {
t.Fatalf("view status = %d, want %d", viewResp.StatusCode, http.StatusOK)
}
viewBody, err := io.ReadAll(viewResp.Body)
if err != nil {
t.Fatalf("ReadAll(view) error = %v", err)
}
if !strings.Contains(string(viewBody), `id="app"`) {
t.Fatalf("view body missing SPA mount point")
}
}