package main import ( "bufio" "bytes" "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "net/url" "os" "os/exec" "path/filepath" "strings" "syscall" "testing" "time" "github.com/gofiber/fiber/v3" "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) func TestAPIProxyPreservesMethodPathAndStatus(t *testing.T) { requests := make(chan *http.Request, 1) gateway := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { requests <- request response.WriteHeader(http.StatusServiceUnavailable) })) defer gateway.Close() upstream, err := url.Parse(gateway.URL) if err != nil { t.Fatal(err) } request := httptest.NewRequest(http.MethodPost, "/api/browsers/account-a/start?force=true", nil) response, err := newHandler(upstream, t.TempDir()).Test(request) if err != nil { t.Fatal(err) } defer response.Body.Close() proxied := <-requests if proxied.Method != http.MethodPost || proxied.URL.Path != "/v1/browsers/account-a/start" || proxied.URL.RawQuery != "force=true" { t.Fatalf("unexpected proxy request %s %s", proxied.Method, proxied.URL.String()) } if response.StatusCode != http.StatusServiceUnavailable { t.Fatalf("expected upstream status 503, got %d", response.StatusCode) } } func TestLoadConfigRejectsInvalidGateway(t *testing.T) { t.Setenv("DOCKER_GATEWAY_URL", "file:///var/run/docker.sock") if _, err := loadConfig(); err == nil { t.Fatal("expected invalid gateway URL to be rejected") } } func TestLoadConfigRejectsInvalidDatabase(t *testing.T) { t.Setenv("DATABASE_URL", "file:///tmp/creatorhub.db") if _, err := loadConfig(); err == nil { t.Fatal("expected non-Postgres database URL to be rejected") } } func TestExecuteContextStopsOnSIGTERM(t *testing.T) { if os.Getenv("CREATORHUB_SIGTERM_HELPER") == "1" { command := &cobra.Command{Use: "shutdown-test", RunE: func(command *cobra.Command, _ []string) error { _, _ = os.Stdout.WriteString("ready\n") <-command.Context().Done() _, _ = os.Stdout.WriteString("stopped\n") return nil }} if err := execute(command); err != nil { t.Fatal(err) } return } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() process := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestExecuteContextStopsOnSIGTERM$") process.Env = append(os.Environ(), "CREATORHUB_SIGTERM_HELPER=1") stdout, err := process.StdoutPipe() if err != nil { t.Fatal(err) } if err := process.Start(); err != nil { t.Fatal(err) } scanner := bufio.NewScanner(stdout) if !scanner.Scan() || scanner.Text() != "ready" { t.Fatalf("helper did not become ready: %q err=%v", scanner.Text(), scanner.Err()) } if err := process.Process.Signal(syscall.SIGTERM); err != nil { t.Fatal(err) } if !scanner.Scan() || scanner.Text() != "stopped" { t.Fatalf("helper did not finish cleanup: %q err=%v", scanner.Text(), scanner.Err()) } if err := process.Wait(); err != nil || ctx.Err() != nil { t.Fatalf("process did not exit gracefully after SIGTERM: wait=%v context=%v", err, ctx.Err()) } } func TestStartupLogRedactsGatewayURL(t *testing.T) { gateway, err := url.Parse("https://internal-user:internal-pass@docker-gateway:8443/v1?access_token=internal-token") if err != nil { t.Fatal(err) } logger := logrus.StandardLogger() previousOutput, previousFormatter, previousLevel := logger.Out, logger.Formatter, logger.Level t.Cleanup(func() { logrus.SetOutput(previousOutput) logrus.SetFormatter(previousFormatter) logrus.SetLevel(previousLevel) }) var output bytes.Buffer logrus.SetOutput(&output) logrus.SetFormatter(&logrus.JSONFormatter{}) logrus.SetLevel(logrus.InfoLevel) logStartup(config{listenAddr: ":8080", gateway: gateway}) var entry map[string]any if err := json.Unmarshal(output.Bytes(), &entry); err != nil { t.Fatal(err) } if entry["docker_gateway"] != "https://docker-gateway:8443" { t.Fatalf("unexpected redacted gateway: %#v", entry["docker_gateway"]) } for _, sensitive := range []string{"internal-user", "internal-pass", "access_token", "internal-token"} { if strings.Contains(output.String(), sensitive) { t.Fatalf("startup log leaked %q: %s", sensitive, output.String()) } } } func TestPhaseARequestRejectsUnknownFields(t *testing.T) { app := fiber.New() app.Post("/", func(c fiber.Ctx) error { var input struct { ID string `json:"id"` } if err := decodePhaseA(c, &input); err != nil { return phaseAError(c, err) } return c.SendStatus(http.StatusNoContent) }) response, err := app.Test(httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"id":"account-a","password":"must-not-be-accepted"}`))) if err != nil { t.Fatal(err) } defer response.Body.Close() if response.StatusCode != http.StatusBadRequest { t.Fatalf("expected unknown sensitive field to be rejected, got %d", response.StatusCode) } } func TestPhaseAErrorRedactsInternalDetails(t *testing.T) { app := fiber.New() app.Get("/", func(c fiber.Ctx) error { return phaseAError(c, errors.New("database exposed a secret value")) }) response, err := app.Test(httptest.NewRequest(http.MethodGet, "/", nil)) if err != nil { t.Fatal(err) } defer response.Body.Close() body, _ := io.ReadAll(response.Body) if response.StatusCode != http.StatusInternalServerError || strings.Contains(string(body), "secret") || !strings.Contains(string(body), "phase A operation failed") { t.Fatalf("internal error was not redacted: status=%d body=%s", response.StatusCode, body) } } func TestSPAServesFileAndIndexFallback(t *testing.T) { directory := t.TempDir() files := map[string]string{ "index.html": "index", "app.js": "asset", "hello world.js": "space", "应用.js": "unicode", } for name, body := range files { if err := os.WriteFile(filepath.Join(directory, name), []byte(body), 0o600); err != nil { t.Fatal(err) } } if err := os.WriteFile(filepath.Join(directory, "..", "outside-secret.txt"), []byte("secret"), 0o600); err != nil { t.Fatal(err) } app := newHandler(&url.URL{Scheme: "http", Host: "127.0.0.1"}, directory) for path, want := range map[string]string{ "/app.js": "asset", "/hello%20world.js": "space", "/%E5%BA%94%E7%94%A8.js": "unicode", "/%2e%2e%2foutside-secret.txt": "index", "/dashboard": "index", } { response, err := app.Test(httptest.NewRequest(http.MethodGet, path, nil)) if err != nil { t.Fatal(err) } body, readErr := io.ReadAll(response.Body) response.Body.Close() if readErr != nil || response.StatusCode != http.StatusOK || string(body) != want { t.Fatalf("GET %s: status=%d body=%q err=%v", path, response.StatusCode, body, readErr) } } }