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
551 lines
15 KiB
Go
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")
|
|
}
|
|
}
|