Skip to content
Snippets Groups Projects
Unverified Commit 00323fa6 authored by Will McCutchen's avatar Will McCutchen Committed by GitHub
Browse files

Refactor package main to enable testing of CLI interface (#98)

parent aaf674eb
No related branches found
No related tags found
No related merge requests found
package main
import (
"context"
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"github.com/mccutchen/go-httpbin/v2/httpbin"
)
const (
defaultHost = "0.0.0.0"
defaultPort = 8080
)
var (
allowedRedirectDomains string
host string
httpsCertFile string
httpsKeyFile string
maxBodySize int64
maxDuration time.Duration
port int
useRealHostname bool
"github.com/mccutchen/go-httpbin/v2/httpbin/cmd"
)
func main() {
flag.BoolVar(&useRealHostname, "use-real-hostname", false, "Expose value of os.Hostname() in the /hostname endpoint instead of dummy value")
flag.DurationVar(&maxDuration, "max-duration", httpbin.DefaultMaxDuration, "Maximum duration a response may take")
flag.Int64Var(&maxBodySize, "max-body-size", httpbin.DefaultMaxBodySize, "Maximum size of request or response, in bytes")
flag.IntVar(&port, "port", defaultPort, "Port to listen on")
flag.StringVar(&allowedRedirectDomains, "allowed-redirect-domains", "", "Comma-separated list of domains the /redirect-to endpoint will allow")
flag.StringVar(&host, "host", defaultHost, "Host to listen on")
flag.StringVar(&httpsCertFile, "https-cert-file", "", "HTTPS Server certificate file")
flag.StringVar(&httpsKeyFile, "https-key-file", "", "HTTPS Server private key file")
flag.Parse()
// Command line flags take precedence over environment vars, so we only
// check for environment vars if we have default values for our command
// line flags.
var err error
if maxBodySize == httpbin.DefaultMaxBodySize && os.Getenv("MAX_BODY_SIZE") != "" {
maxBodySize, err = strconv.ParseInt(os.Getenv("MAX_BODY_SIZE"), 10, 64)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: invalid value %#v for env var MAX_BODY_SIZE: %s\n\n", os.Getenv("MAX_BODY_SIZE"), err)
flag.Usage()
os.Exit(1)
}
}
if maxDuration == httpbin.DefaultMaxDuration && os.Getenv("MAX_DURATION") != "" {
maxDuration, err = time.ParseDuration(os.Getenv("MAX_DURATION"))
if err != nil {
fmt.Fprintf(os.Stderr, "Error: invalid value %#v for env var MAX_DURATION: %s\n\n", os.Getenv("MAX_DURATION"), err)
flag.Usage()
os.Exit(1)
}
}
if host == defaultHost && os.Getenv("HOST") != "" {
host = os.Getenv("HOST")
}
if port == defaultPort && os.Getenv("PORT") != "" {
port, err = strconv.Atoi(os.Getenv("PORT"))
if err != nil {
fmt.Fprintf(os.Stderr, "Error: invalid value %#v for env var PORT: %s\n\n", os.Getenv("PORT"), err)
flag.Usage()
os.Exit(1)
}
}
if httpsCertFile == "" && os.Getenv("HTTPS_CERT_FILE") != "" {
httpsCertFile = os.Getenv("HTTPS_CERT_FILE")
}
if httpsKeyFile == "" && os.Getenv("HTTPS_KEY_FILE") != "" {
httpsKeyFile = os.Getenv("HTTPS_KEY_FILE")
}
var serveTLS bool
if httpsCertFile != "" || httpsKeyFile != "" {
serveTLS = true
if httpsCertFile == "" || httpsKeyFile == "" {
fmt.Fprintf(os.Stderr, "Error: https cert and key must both be provided\n\n")
flag.Usage()
os.Exit(1)
}
}
// useRealHostname will be true if either the `-use-real-hostname`
// arg is given on the command line or if the USE_REAL_HOSTNAME env var
// is one of "1" or "true".
if useRealHostnameEnv := os.Getenv("USE_REAL_HOSTNAME"); useRealHostnameEnv == "1" || useRealHostnameEnv == "true" {
useRealHostname = true
}
var allowedRedirectDomainsList []string
if allowedRedirectDomains == "" && os.Getenv("ALLOWED_REDIRECT_DOMAINS") != "" {
allowedRedirectDomains = os.Getenv("ALLOWED_REDIRECT_DOMAINS")
}
for _, domain := range strings.Split(allowedRedirectDomains, ",") {
if strings.TrimSpace(domain) != "" {
allowedRedirectDomainsList = append(allowedRedirectDomainsList, strings.TrimSpace(domain))
}
}
logger := log.New(os.Stderr, "", 0)
// A hacky log helper function to ensure that shutdown messages are
// formatted the same as other messages. See StdLogObserver in
// httpbin/middleware.go for the format we're matching here.
serverLog := func(msg string, args ...interface{}) {
const (
logFmt = "time=%q msg=%q"
dateFmt = "2006-01-02T15:04:05.9999"
)
logger.Printf(logFmt, time.Now().Format(dateFmt), fmt.Sprintf(msg, args...))
}
opts := []httpbin.OptionFunc{
httpbin.WithMaxBodySize(maxBodySize),
httpbin.WithMaxDuration(maxDuration),
httpbin.WithObserver(httpbin.StdLogObserver(logger)),
}
if useRealHostname {
hostname, err := os.Hostname()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: use-real-hostname=true but hostname lookup failed: %s\n", err)
os.Exit(1)
}
opts = append(opts, httpbin.WithHostname(hostname))
}
if len(allowedRedirectDomainsList) > 0 {
opts = append(opts, httpbin.WithAllowedRedirectDomains(allowedRedirectDomainsList))
}
h := httpbin.New(opts...)
listenAddr := net.JoinHostPort(host, strconv.Itoa(port))
server := &http.Server{
Addr: listenAddr,
Handler: h.Handler(),
}
// shutdownCh triggers graceful shutdown on SIGINT or SIGTERM
shutdownCh := make(chan os.Signal, 1)
signal.Notify(shutdownCh, syscall.SIGINT, syscall.SIGTERM)
// exitCh will be closed when it is safe to exit, after graceful shutdown
exitCh := make(chan struct{})
go func() {
sig := <-shutdownCh
serverLog("shutdown started by signal: %s", sig)
shutdownTimeout := maxDuration + 1*time.Second
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
server.SetKeepAlivesEnabled(false)
if err := server.Shutdown(ctx); err != nil {
serverLog("shutdown error: %s", err)
}
close(exitCh)
}()
var listenErr error
if serveTLS {
serverLog("go-httpbin listening on https://%s", listenAddr)
listenErr = server.ListenAndServeTLS(httpsCertFile, httpsKeyFile)
} else {
serverLog("go-httpbin listening on http://%s", listenAddr)
listenErr = server.ListenAndServe()
}
if listenErr != nil && listenErr != http.ErrServerClosed {
logger.Fatalf("failed to listen: %s", listenErr)
}
<-exitCh
serverLog("shutdown finished")
os.Exit(cmd.Main())
}
package cmd
import (
"bytes"
"context"
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"github.com/mccutchen/go-httpbin/v2/httpbin"
)
const (
defaultListenHost = "0.0.0.0"
defaultListenPort = 8080
// Reasonable defaults for our http server
srvReadTimeout = 5 * time.Second
srvReadHeaderTimeout = 1 * time.Second
srvMaxHeaderBytes = 16 * 1024 // 16kb
)
// Main is the main entrypoint for the go-httpbin binary. See loadConfig() for
// command line argument parsing.
func Main() int {
return mainImpl(os.Args[1:], os.Getenv, os.Hostname, os.Stderr)
}
// mainImpl is the real implementation of Main(), extracted for better
// testability.
func mainImpl(args []string, getEnv func(string) string, getHostname func() (string, error), out io.Writer) int {
logger := log.New(out, "", 0)
cfg, err := loadConfig(args, getEnv, getHostname)
if err != nil {
if cfgErr, ok := err.(ConfigError); ok {
// for -h/-help, just print usage and exit without error
if cfgErr.Err == flag.ErrHelp {
fmt.Fprint(out, cfgErr.Usage)
return 0
}
// anything else indicates a problem with CLI arguments and/or
// environment vars, so print error and usage and exit with an
// error status.
//
// note: seems like there's consensus that an exit code of 2 is
// often used to indicate a problem with the way a command was
// called, e.g.:
// https://stackoverflow.com/a/40484670/151221
// https://linuxconfig.org/list-of-exit-codes-on-linux
fmt.Fprintf(out, "error: %s\n\n%s", cfgErr.Err, cfgErr.Usage)
return 2
}
fmt.Fprintf(out, "error: %s", err)
return 1
}
opts := []httpbin.OptionFunc{
httpbin.WithMaxBodySize(cfg.MaxBodySize),
httpbin.WithMaxDuration(cfg.MaxDuration),
httpbin.WithObserver(httpbin.StdLogObserver(logger)),
}
if cfg.RealHostname != "" {
opts = append(opts, httpbin.WithHostname(cfg.RealHostname))
}
if len(cfg.AllowedRedirectDomains) > 0 {
opts = append(opts, httpbin.WithAllowedRedirectDomains(cfg.AllowedRedirectDomains))
}
app := httpbin.New(opts...)
srv := &http.Server{
Addr: net.JoinHostPort(cfg.ListenHost, strconv.Itoa(cfg.ListenPort)),
Handler: app.Handler(),
MaxHeaderBytes: srvMaxHeaderBytes,
ReadHeaderTimeout: srvReadHeaderTimeout,
ReadTimeout: srvReadTimeout,
}
if err := listenAndServeGracefully(srv, cfg, logger); err != nil {
logger.Printf("error: %s", err)
return 1
}
return 0
}
// config holds the configuration needed to initialize and run go-httpbin as a
// standalone server.
type config struct {
AllowedRedirectDomains []string
ListenHost string
ListenPort int
MaxBodySize int64
MaxDuration time.Duration
RealHostname string
TLSCertFile string
TLSKeyFile string
// temporary placeholders for arguments that need extra processing
rawAllowedRedirectDomains string
rawUseRealHostname bool
}
// ConfigError is used to signal an error with a command line argument or
// environmment variable.
//
// It carries the command's usage output, so that we can decouple configuration
// parsing from error reporting for better testability.
type ConfigError struct {
Err error
Usage string
}
// Error implements the error interface.
func (e ConfigError) Error() string {
return e.Err.Error()
}
// loadConfig parses command line arguments and env vars into a fully resolved
// Config struct. Command line arguments take precedence over env vars.
func loadConfig(args []string, getEnv func(string) string, getHostname func() (string, error)) (*config, error) {
cfg := &config{}
fs := flag.NewFlagSet("", flag.ContinueOnError)
fs.BoolVar(&cfg.rawUseRealHostname, "use-real-hostname", false, "Expose value of os.Hostname() in the /hostname endpoint instead of dummy value")
fs.DurationVar(&cfg.MaxDuration, "max-duration", httpbin.DefaultMaxDuration, "Maximum duration a response may take")
fs.Int64Var(&cfg.MaxBodySize, "max-body-size", httpbin.DefaultMaxBodySize, "Maximum size of request or response, in bytes")
fs.IntVar(&cfg.ListenPort, "port", defaultListenPort, "Port to listen on")
fs.StringVar(&cfg.rawAllowedRedirectDomains, "allowed-redirect-domains", "", "Comma-separated list of domains the /redirect-to endpoint will allow")
fs.StringVar(&cfg.ListenHost, "host", defaultListenHost, "Host to listen on")
fs.StringVar(&cfg.TLSCertFile, "https-cert-file", "", "HTTPS Server certificate file")
fs.StringVar(&cfg.TLSKeyFile, "https-key-file", "", "HTTPS Server private key file")
// in order to fully control error output whether CLI arguments or env vars
// are used to configure the app, we need to take control away from the
// flagset, which by defaults prints errors automatically.
//
// so, we capture the "usage" output it would generate and then trick it
// into generating no output on errors, since they'll be handled by the
// caller.
//
// yes, this is goofy, but it makes the CLI testable!
buf := &bytes.Buffer{}
fs.SetOutput(buf)
fs.Usage()
usage := buf.String()
fs.SetOutput(io.Discard)
if err := fs.Parse(args); err != nil {
return nil, ConfigError{err, usage}
}
// helper to generate a new ConfigError to return
configErr := func(format string, a ...interface{}) error {
return ConfigError{
Err: fmt.Errorf(format, a...),
Usage: usage,
}
}
var err error
// Command line flags take precedence over environment vars, so we only
// check for environment vars if we have default values for our command
// line flags.
if cfg.MaxBodySize == httpbin.DefaultMaxBodySize && getEnv("MAX_BODY_SIZE") != "" {
cfg.MaxBodySize, err = strconv.ParseInt(getEnv("MAX_BODY_SIZE"), 10, 64)
if err != nil {
return nil, configErr("invalid value %#v for env var MAX_BODY_SIZE: parse error", getEnv("MAX_BODY_SIZE"))
}
}
if cfg.MaxDuration == httpbin.DefaultMaxDuration && getEnv("MAX_DURATION") != "" {
cfg.MaxDuration, err = time.ParseDuration(getEnv("MAX_DURATION"))
if err != nil {
return nil, configErr("invalid value %#v for env var MAX_DURATION: parse error", getEnv("MAX_DURATION"))
}
}
if cfg.ListenHost == defaultListenHost && getEnv("HOST") != "" {
cfg.ListenHost = getEnv("HOST")
}
if cfg.ListenPort == defaultListenPort && getEnv("PORT") != "" {
cfg.ListenPort, err = strconv.Atoi(getEnv("PORT"))
if err != nil {
return nil, configErr("invalid value %#v for env var PORT: parse error", getEnv("PORT"))
}
}
if cfg.TLSCertFile == "" && getEnv("HTTPS_CERT_FILE") != "" {
cfg.TLSCertFile = getEnv("HTTPS_CERT_FILE")
}
if cfg.TLSKeyFile == "" && getEnv("HTTPS_KEY_FILE") != "" {
cfg.TLSKeyFile = getEnv("HTTPS_KEY_FILE")
}
if cfg.TLSCertFile != "" || cfg.TLSKeyFile != "" {
if cfg.TLSCertFile == "" || cfg.TLSKeyFile == "" {
return nil, configErr("https cert and key must both be provided")
}
}
// useRealHostname will be true if either the `-use-real-hostname`
// arg is given on the command line or if the USE_REAL_HOSTNAME env var
// is one of "1" or "true".
if useRealHostnameEnv := getEnv("USE_REAL_HOSTNAME"); useRealHostnameEnv == "1" || useRealHostnameEnv == "true" {
cfg.rawUseRealHostname = true
}
if cfg.rawUseRealHostname {
cfg.RealHostname, err = getHostname()
if err != nil {
return nil, fmt.Errorf("could not look up real hostname: %w", err)
}
}
// split comma-separated list of domains into a slice, if given
if cfg.rawAllowedRedirectDomains == "" && getEnv("ALLOWED_REDIRECT_DOMAINS") != "" {
cfg.rawAllowedRedirectDomains = getEnv("ALLOWED_REDIRECT_DOMAINS")
}
for _, domain := range strings.Split(cfg.rawAllowedRedirectDomains, ",") {
if strings.TrimSpace(domain) != "" {
cfg.AllowedRedirectDomains = append(cfg.AllowedRedirectDomains, strings.TrimSpace(domain))
}
}
// reset temporary fields to their zero values
cfg.rawAllowedRedirectDomains = ""
cfg.rawUseRealHostname = false
return cfg, nil
}
func listenAndServeGracefully(srv *http.Server, cfg *config, logger *log.Logger) error {
doneCh := make(chan error, 1)
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
<-sigCh
logger.Printf("shutting down ...")
ctx, cancel := context.WithTimeout(context.Background(), cfg.MaxDuration+1*time.Second)
defer cancel()
doneCh <- srv.Shutdown(ctx)
}()
var err error
if cfg.TLSCertFile != "" && cfg.TLSKeyFile != "" {
logger.Printf("go-httpbin listening on https://%s", srv.Addr)
err = srv.ListenAndServeTLS(cfg.TLSCertFile, cfg.TLSKeyFile)
} else {
logger.Printf("go-httpbin listening on http://%s", srv.Addr)
err = srv.ListenAndServe()
}
if err != nil && err != http.ErrServerClosed {
return err
}
return <-doneCh
}
package cmd
import (
"bytes"
"errors"
"flag"
"os"
"reflect"
"testing"
"time"
"github.com/mccutchen/go-httpbin/v2/httpbin"
)
// To update, run:
// make && ./dist/go-httpbin -h 2>&1 | pbcopy
const usage = `Usage:
-allowed-redirect-domains string
Comma-separated list of domains the /redirect-to endpoint will allow
-host string
Host to listen on (default "0.0.0.0")
-https-cert-file string
HTTPS Server certificate file
-https-key-file string
HTTPS Server private key file
-max-body-size int
Maximum size of request or response, in bytes (default 1048576)
-max-duration duration
Maximum duration a response may take (default 10s)
-port int
Port to listen on (default 8080)
-use-real-hostname
Expose value of os.Hostname() in the /hostname endpoint instead of dummy value
`
func TestLoadConfig(t *testing.T) {
t.Parallel()
testDefaultRealHostname := "real-hostname.test"
getHostnameDefault := func() (string, error) {
return testDefaultRealHostname, nil
}
testCases := map[string]struct {
args []string
env map[string]string
getHostname func() (string, error)
wantCfg *config
wantErr error
wantOut string
}{
"defaults": {
wantCfg: &config{
ListenHost: "0.0.0.0",
ListenPort: 8080,
MaxBodySize: httpbin.DefaultMaxBodySize,
MaxDuration: httpbin.DefaultMaxDuration,
},
},
"-h": {
args: []string{"-h"},
wantErr: flag.ErrHelp,
},
"-help": {
args: []string{"-help"},
wantErr: flag.ErrHelp,
},
// max body size
"invalid -max-body-size": {
args: []string{"-max-body-size", "foo"},
wantErr: errors.New("invalid value \"foo\" for flag -max-body-size: parse error"),
},
"invalid MAX_BODY_SIZE": {
env: map[string]string{"MAX_BODY_SIZE": "foo"},
wantErr: errors.New("invalid value \"foo\" for env var MAX_BODY_SIZE: parse error"),
},
"ok -max-body-size": {
args: []string{"-max-body-size", "99"},
wantCfg: &config{
ListenHost: "0.0.0.0",
ListenPort: 8080,
MaxBodySize: 99,
MaxDuration: httpbin.DefaultMaxDuration,
},
},
"ok MAX_BODY_SIZE": {
env: map[string]string{"MAX_BODY_SIZE": "9999"},
wantCfg: &config{
ListenHost: "0.0.0.0",
ListenPort: 8080,
MaxBodySize: 9999,
MaxDuration: httpbin.DefaultMaxDuration,
},
},
"ok max body size CLI takes precedence over env": {
args: []string{"-max-body-size", "1234"},
env: map[string]string{"MAX_BODY_SIZE": "5678"},
wantCfg: &config{
ListenHost: "0.0.0.0",
ListenPort: 8080,
MaxBodySize: 1234,
MaxDuration: httpbin.DefaultMaxDuration,
},
},
// max duration
"invalid -max-duration": {
args: []string{"-max-duration", "foo"},
wantErr: errors.New("invalid value \"foo\" for flag -max-duration: parse error"),
},
"invalid MAX_DURATION": {
env: map[string]string{"MAX_DURATION": "foo"},
wantErr: errors.New("invalid value \"foo\" for env var MAX_DURATION: parse error"),
},
"ok -max-duration": {
args: []string{"-max-duration", "99s"},
wantCfg: &config{
ListenHost: "0.0.0.0",
ListenPort: 8080,
MaxBodySize: httpbin.DefaultMaxBodySize,
MaxDuration: 99 * time.Second,
},
},
"ok MAX_DURATION": {
env: map[string]string{"MAX_DURATION": "9999s"},
wantCfg: &config{
ListenHost: "0.0.0.0",
ListenPort: 8080,
MaxBodySize: httpbin.DefaultMaxBodySize,
MaxDuration: 9999 * time.Second,
},
},
"ok max duration size CLI takes precedence over env": {
args: []string{"-max-duration", "1234s"},
env: map[string]string{"MAX_DURATION": "5678s"},
wantCfg: &config{
ListenHost: "0.0.0.0",
ListenPort: 8080,
MaxBodySize: httpbin.DefaultMaxBodySize,
MaxDuration: 1234 * time.Second,
},
},
// host
"ok -host": {
args: []string{"-host", "192.0.0.1"},
wantCfg: &config{
ListenHost: "192.0.0.1",
ListenPort: 8080,
MaxBodySize: httpbin.DefaultMaxBodySize,
MaxDuration: httpbin.DefaultMaxDuration,
},
},
"ok HOST": {
env: map[string]string{"HOST": "192.0.0.2"},
wantCfg: &config{
ListenHost: "192.0.0.2",
ListenPort: 8080,
MaxBodySize: httpbin.DefaultMaxBodySize,
MaxDuration: httpbin.DefaultMaxDuration,
},
},
"ok host cli takes precedence over end": {
args: []string{"-host", "99.99.99.99"},
env: map[string]string{"HOST": "11.11.11.11"},
wantCfg: &config{
ListenHost: "99.99.99.99",
ListenPort: 8080,
MaxBodySize: httpbin.DefaultMaxBodySize,
MaxDuration: httpbin.DefaultMaxDuration,
},
},
// port
"invalid -port": {
args: []string{"-port", "foo"},
wantErr: errors.New("invalid value \"foo\" for flag -port: parse error"),
},
"invalid PORT": {
env: map[string]string{"PORT": "foo"},
wantErr: errors.New("invalid value \"foo\" for env var PORT: parse error"),
},
"ok -port": {
args: []string{"-port", "99"},
wantCfg: &config{
ListenHost: defaultListenHost,
ListenPort: 99,
MaxBodySize: httpbin.DefaultMaxBodySize,
MaxDuration: httpbin.DefaultMaxDuration,
},
},
"ok PORT": {
env: map[string]string{"PORT": "9999"},
wantCfg: &config{
ListenHost: defaultListenHost,
ListenPort: 9999,
MaxBodySize: httpbin.DefaultMaxBodySize,
MaxDuration: httpbin.DefaultMaxDuration,
},
},
"ok port CLI takes precedence over env": {
args: []string{"-port", "1234"},
env: map[string]string{"PORT": "5678"},
wantCfg: &config{
ListenHost: defaultListenHost,
ListenPort: 1234,
MaxBodySize: httpbin.DefaultMaxBodySize,
MaxDuration: httpbin.DefaultMaxDuration,
},
},
// https cert file
"https cert and key must both be provided, cert only": {
args: []string{"-https-cert-file", "/tmp/test.crt"},
wantErr: errors.New("https cert and key must both be provided"),
},
"https cert and key must both be provided, key only": {
args: []string{"-https-key-file", "/tmp/test.crt"},
wantErr: errors.New("https cert and key must both be provided"),
},
"ok https CLI": {
args: []string{
"-https-cert-file", "/tmp/test.crt",
"-https-key-file", "/tmp/test.key",
},
wantCfg: &config{
ListenHost: "0.0.0.0",
ListenPort: 8080,
MaxBodySize: httpbin.DefaultMaxBodySize,
MaxDuration: httpbin.DefaultMaxDuration,
TLSCertFile: "/tmp/test.crt",
TLSKeyFile: "/tmp/test.key",
},
},
"ok https env": {
env: map[string]string{
"HTTPS_CERT_FILE": "/tmp/test.crt",
"HTTPS_KEY_FILE": "/tmp/test.key",
},
wantCfg: &config{
ListenHost: "0.0.0.0",
ListenPort: 8080,
MaxBodySize: httpbin.DefaultMaxBodySize,
MaxDuration: httpbin.DefaultMaxDuration,
TLSCertFile: "/tmp/test.crt",
TLSKeyFile: "/tmp/test.key",
},
},
"ok https CLI takes precedence over env": {
args: []string{
"-https-cert-file", "/tmp/cli.crt",
"-https-key-file", "/tmp/cli.key",
},
env: map[string]string{
"HTTPS_CERT_FILE": "/tmp/env.crt",
"HTTPS_KEY_FILE": "/tmp/env.key",
},
wantCfg: &config{
ListenHost: "0.0.0.0",
ListenPort: 8080,
MaxBodySize: httpbin.DefaultMaxBodySize,
MaxDuration: httpbin.DefaultMaxDuration,
TLSCertFile: "/tmp/cli.crt",
TLSKeyFile: "/tmp/cli.key",
},
},
// use-real-hostname
"ok -use-real-hostname": {
args: []string{"-use-real-hostname"},
wantCfg: &config{
ListenHost: "0.0.0.0",
ListenPort: 8080,
MaxBodySize: httpbin.DefaultMaxBodySize,
MaxDuration: httpbin.DefaultMaxDuration,
RealHostname: testDefaultRealHostname,
},
},
"ok -use-real-hostname=1": {
args: []string{"-use-real-hostname", "1"},
wantCfg: &config{
ListenHost: "0.0.0.0",
ListenPort: 8080,
MaxBodySize: httpbin.DefaultMaxBodySize,
MaxDuration: httpbin.DefaultMaxDuration,
RealHostname: testDefaultRealHostname,
},
},
"ok -use-real-hostname=true": {
args: []string{"-use-real-hostname", "true"},
wantCfg: &config{
ListenHost: "0.0.0.0",
ListenPort: 8080,
MaxBodySize: httpbin.DefaultMaxBodySize,
MaxDuration: httpbin.DefaultMaxDuration,
RealHostname: testDefaultRealHostname,
},
},
// any value for the argument is interpreted as true
"ok -use-real-hostname=0": {
args: []string{"-use-real-hostname", "0"},
wantCfg: &config{
ListenHost: "0.0.0.0",
ListenPort: 8080,
MaxBodySize: httpbin.DefaultMaxBodySize,
MaxDuration: httpbin.DefaultMaxDuration,
RealHostname: testDefaultRealHostname,
},
},
"ok USE_REAL_HOSTNAME=1": {
env: map[string]string{"USE_REAL_HOSTNAME": "1"},
wantCfg: &config{
ListenHost: "0.0.0.0",
ListenPort: 8080,
MaxBodySize: httpbin.DefaultMaxBodySize,
MaxDuration: httpbin.DefaultMaxDuration,
RealHostname: testDefaultRealHostname,
},
},
"ok USE_REAL_HOSTNAME=true": {
env: map[string]string{"USE_REAL_HOSTNAME": "true"},
wantCfg: &config{
ListenHost: "0.0.0.0",
ListenPort: 8080,
MaxBodySize: httpbin.DefaultMaxBodySize,
MaxDuration: httpbin.DefaultMaxDuration,
RealHostname: testDefaultRealHostname,
},
},
// case sensitive
"ok USE_REAL_HOSTNAME=TRUE": {
env: map[string]string{"USE_REAL_HOSTNAME": "TRUE"},
wantCfg: &config{
ListenHost: "0.0.0.0",
ListenPort: 8080,
MaxBodySize: httpbin.DefaultMaxBodySize,
MaxDuration: httpbin.DefaultMaxDuration,
},
},
"ok USE_REAL_HOSTNAME=false": {
env: map[string]string{"USE_REAL_HOSTNAME": "false"},
wantCfg: &config{
ListenHost: "0.0.0.0",
ListenPort: 8080,
MaxBodySize: httpbin.DefaultMaxBodySize,
MaxDuration: httpbin.DefaultMaxDuration,
},
},
"err real hostname error": {
env: map[string]string{"USE_REAL_HOSTNAME": "true"},
getHostname: func() (string, error) { return "", errors.New("hostname error") },
wantErr: errors.New("could not look up real hostname: hostname error"),
},
// allowed-redirect-domains
"ok -allowed-redirect-domains": {
args: []string{"-allowed-redirect-domains", "foo,bar"},
wantCfg: &config{
ListenHost: "0.0.0.0",
ListenPort: 8080,
MaxBodySize: httpbin.DefaultMaxBodySize,
MaxDuration: httpbin.DefaultMaxDuration,
AllowedRedirectDomains: []string{"foo", "bar"},
},
},
"ok ALLOWED_REDIRECT_DOMAINS": {
env: map[string]string{"ALLOWED_REDIRECT_DOMAINS": "foo,bar"},
wantCfg: &config{
ListenHost: "0.0.0.0",
ListenPort: 8080,
MaxBodySize: httpbin.DefaultMaxBodySize,
MaxDuration: httpbin.DefaultMaxDuration,
AllowedRedirectDomains: []string{"foo", "bar"},
},
},
"ok allowed redirect domains CLI takes precedence over env": {
args: []string{"-allowed-redirect-domains", "foo.cli,bar.cli"},
env: map[string]string{"ALLOWED_REDIRECT_DOMAINS": "foo.env,bar.env"},
wantCfg: &config{
ListenHost: "0.0.0.0",
ListenPort: 8080,
MaxBodySize: httpbin.DefaultMaxBodySize,
MaxDuration: httpbin.DefaultMaxDuration,
AllowedRedirectDomains: []string{"foo.cli", "bar.cli"},
},
},
"ok allowed redirect domains are normalized": {
args: []string{"-allowed-redirect-domains", "foo, bar ,, baz "},
wantCfg: &config{
ListenHost: "0.0.0.0",
ListenPort: 8080,
MaxBodySize: httpbin.DefaultMaxBodySize,
MaxDuration: httpbin.DefaultMaxDuration,
AllowedRedirectDomains: []string{"foo", "bar", "baz"},
},
},
}
for name, tc := range testCases {
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
if tc.getHostname == nil {
tc.getHostname = getHostnameDefault
}
cfg, err := loadConfig(tc.args, func(key string) string { return tc.env[key] }, tc.getHostname)
switch {
case tc.wantErr != nil && err != nil:
if tc.wantErr.Error() != err.Error() {
t.Fatalf("incorrect error\nwant: %q\ngot: %q", tc.wantErr, err)
}
case tc.wantErr != nil:
t.Fatalf("want error %q, got nil", tc.wantErr)
case err != nil:
t.Fatalf("got unexpected error: %q", err)
}
if !reflect.DeepEqual(tc.wantCfg, cfg) {
t.Fatalf("bad config\nwant: %#v\ngot: %#v", tc.wantCfg, cfg)
}
})
}
}
func TestMainImpl(t *testing.T) {
t.Parallel()
testCases := map[string]struct {
args []string
env map[string]string
getHostname func() (string, error)
wantCode int
wantOut string
}{
"help": {
args: []string{"-h"},
wantCode: 0,
wantOut: usage,
},
"cli error": {
args: []string{"-max-body-size", "foo"},
wantCode: 2,
wantOut: "error: invalid value \"foo\" for flag -max-body-size: parse error\n\n" + usage,
},
"unknown argument": {
args: []string{"-zzz"},
wantCode: 2,
wantOut: "error: flag provided but not defined: -zzz\n\n" + usage,
},
"real hostname error": {
args: []string{"-use-real-hostname"},
getHostname: func() (string, error) { return "", errors.New("hostname failure") },
wantCode: 1,
wantOut: "error: could not look up real hostname: hostname failure",
},
"server error": {
args: []string{"-port", "-256"},
wantCode: 1,
wantOut: "go-httpbin listening on http://0.0.0.0:-256\nerror: listen tcp: address -256: invalid port\n",
},
"tls cert error": {
args: []string{
"-port", "0",
"-https-cert-file", "./https-cert-does-not-exist",
"-https-key-file", "./https-key-does-not-exist",
},
wantCode: 1,
wantOut: "go-httpbin listening on https://0.0.0.0:0\nerror: open ./https-cert-does-not-exist: no such file or directory\n",
},
}
for name, tc := range testCases {
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
if tc.getHostname == nil {
tc.getHostname = os.Hostname
}
buf := &bytes.Buffer{}
gotCode := mainImpl(tc.args, func(key string) string { return tc.env[key] }, tc.getHostname, buf)
out := buf.String()
if gotCode != tc.wantCode {
t.Logf("unexpected error: output:\n%s", out)
t.Fatalf("expected return code %d, got %d", tc.wantCode, gotCode)
}
if out != tc.wantOut {
t.Fatalf("output mismatch error:\nwant: %q\ngot: %q", tc.wantOut, out)
}
})
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment