A SimpleX Chat bot that answers arithmetic (closes #1)
check / check (push) Successful in 54s
check / check (push) Successful in 54s
Remove the template's HTTP service, database and fx wiring. Add exact arithmetic on go/parser and go/constant, a client that runs simplex-chat as a child process and drives its WebSocket API, and the bot, which keeps an auto-accepting address and replies to each message. The image adds the checksum-pinned simplex-chat v7.0.2 on Ubuntu 22.04. Model: opus-5-5
This commit is contained in:
+26
-279
@@ -3,34 +3,30 @@
|
||||
//
|
||||
// The iron rule of this package: a value that is SET but cannot be
|
||||
// parsed aborts startup. It is never replaced by the default. An
|
||||
// operator who writes PORT=eighty has said something specific and
|
||||
// wrong, and starting anyway on port 8080 turns their mistake into a
|
||||
// silent misconfiguration that only surfaces much later, somewhere
|
||||
// else. Defaults apply to values that are ABSENT, and to nothing else.
|
||||
// operator who writes DEBUG=yes has said something specific that this
|
||||
// program does not understand, and starting anyway with the default
|
||||
// turns their mistake into a silent misconfiguration that only
|
||||
// surfaces much later, somewhere else. Defaults apply to values that
|
||||
// are ABSENT, and to nothing else.
|
||||
//
|
||||
// Every parse failure found in one pass is reported together, so a
|
||||
// broken deployment takes one restart to diagnose rather than five.
|
||||
// broken deployment takes one restart to diagnose rather than several.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/spf13/viper"
|
||||
"go.uber.org/fx"
|
||||
|
||||
// spooky action at a distance!
|
||||
// this populates the environment
|
||||
// from a ./.env file automatically
|
||||
// for development configuration.
|
||||
// .env contents should be things like
|
||||
// `PORT=8080`
|
||||
// `DEBUG=true`
|
||||
// (without the backticks, of course)
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
)
|
||||
@@ -38,54 +34,12 @@ import (
|
||||
// Environment variable names. Bare names, no prefix: this matches the
|
||||
// other services and keeps a compose file readable.
|
||||
const (
|
||||
EnvPort = "PORT"
|
||||
EnvDataDir = "DATA_DIR"
|
||||
EnvDBPath = "DB_PATH"
|
||||
EnvDebug = "DEBUG"
|
||||
EnvHSTS = "HSTS"
|
||||
EnvBaseURL = "BASE_URL"
|
||||
EnvMaxRequestBody = "MAX_REQUEST_BODY"
|
||||
EnvRequestTimeout = "REQUEST_TIMEOUT"
|
||||
EnvShutdownGrace = "SHUTDOWN_GRACE"
|
||||
EnvSentryDSN = "SENTRY_DSN"
|
||||
EnvSentryEnv = "SENTRY_ENVIRONMENT"
|
||||
EnvMetricsUser = "METRICS_USER"
|
||||
EnvMetricsPassword = "METRICS_PASSWORD"
|
||||
EnvCSRFKey = "CSRF_KEY"
|
||||
EnvDataDir = "DATA_DIR"
|
||||
EnvDebug = "DEBUG"
|
||||
)
|
||||
|
||||
// Defaults for values that are absent. A value that is present and
|
||||
// unparseable never reaches these.
|
||||
const (
|
||||
DefaultPort int64 = 8080
|
||||
DefaultDataDir = "./data"
|
||||
DefaultBaseURL = "http://localhost:8080"
|
||||
DefaultMaxRequestBody int64 = 1 << 20 // 1 MiB
|
||||
DefaultRequestTimeout = 30 * time.Second
|
||||
DefaultShutdownGrace = 15 * time.Second
|
||||
DefaultSentryEnv = "development"
|
||||
)
|
||||
|
||||
// Bounds. A value inside the type but outside the range is as
|
||||
// misconfigured as one that does not parse, and fails the same way.
|
||||
const (
|
||||
minPort int64 = 1
|
||||
maxPort int64 = 65535
|
||||
|
||||
// minRequestBody is a floor below which no useful form submission
|
||||
// fits; maxRequestBody is a ceiling above which the cap is not
|
||||
// doing its job.
|
||||
minRequestBody int64 = 1 << 10 // 1 KiB
|
||||
maxRequestBody int64 = 1 << 26 // 64 MiB
|
||||
|
||||
minTimeout = 1 * time.Second
|
||||
maxTimeout = 10 * time.Minute
|
||||
|
||||
// csrfKeyBytes is what gorilla/csrf requires: exactly 32 bytes,
|
||||
// supplied as csrfKeyHexChars hex characters.
|
||||
csrfKeyBytes = 32
|
||||
csrfKeyHexChars = csrfKeyBytes * 2
|
||||
)
|
||||
// DefaultDataDir applies when DATA_DIR is absent.
|
||||
const DefaultDataDir = "./data"
|
||||
|
||||
// ErrInvalidConfig is the sentinel every configuration failure wraps,
|
||||
// so callers can distinguish "the operator got it wrong" from "the
|
||||
@@ -95,43 +49,12 @@ var ErrInvalidConfig = errors.New("invalid configuration")
|
||||
// Config is the parsed, validated runtime configuration. Every field
|
||||
// is final by the time New returns: nothing re-reads the environment
|
||||
// later, so there is exactly one moment at which configuration can be
|
||||
// wrong, and it is before the listener opens.
|
||||
// wrong, and it is before anything starts.
|
||||
type Config struct {
|
||||
Port int
|
||||
DataDir string
|
||||
DBPath string
|
||||
Debug bool
|
||||
HSTS bool
|
||||
BaseURL string
|
||||
MaxRequestBody int64
|
||||
RequestTimeout time.Duration
|
||||
ShutdownGrace time.Duration
|
||||
|
||||
SentryDSN string
|
||||
SentryEnvironment string
|
||||
|
||||
// MetricsUser and MetricsPassword gate /metrics. Both set or
|
||||
// neither: half-set is refused rather than resolved, because
|
||||
// either resolution is dangerous. Treating a missing password as
|
||||
// empty would publish the metrics endpoint to anyone who guesses
|
||||
// the username; treating a missing username as "no auth" would
|
||||
// publish it to everyone, in a deployment whose operator plainly
|
||||
// intended it to be closed.
|
||||
MetricsUser string
|
||||
MetricsPassword string
|
||||
|
||||
// CSRFKey is exactly 32 bytes. When CSRF_KEY is absent, a random
|
||||
// key is generated at startup and a warning is logged: tokens then
|
||||
// do not survive a restart, which is fine in development and not
|
||||
// fine behind more than one replica. Absent is a default; present
|
||||
// and malformed is a startup failure.
|
||||
CSRFKey []byte
|
||||
CSRFKeyEphemeral bool
|
||||
}
|
||||
|
||||
// Params defines dependencies for Config.
|
||||
type Params struct {
|
||||
fx.In
|
||||
// DataDir holds the SimpleX Chat database: the bot's profile, its
|
||||
// address and its contacts. Losing it loses the address.
|
||||
DataDir string
|
||||
Debug bool
|
||||
}
|
||||
|
||||
// loader parses one environment into a Config, accumulating every
|
||||
@@ -166,32 +89,10 @@ func (l *loader) str(key, def string) string {
|
||||
return def
|
||||
}
|
||||
|
||||
func (l *loader) integer(key string, def, minVal, maxVal int64) int64 {
|
||||
s, ok := l.raw(key)
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
|
||||
n, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
l.fail(key, s, "not an integer")
|
||||
|
||||
return def
|
||||
}
|
||||
|
||||
if n < minVal || n > maxVal {
|
||||
l.fail(key, s, fmt.Sprintf("outside the range %d..%d", minVal, maxVal))
|
||||
|
||||
return def
|
||||
}
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
// boolean accepts what strconv.ParseBool accepts (1/t/T/TRUE/true/True
|
||||
// and the false equivalents) and refuses everything else. "yes" is a
|
||||
// parse failure on purpose: guessing at it is how a security header
|
||||
// ends up off in production.
|
||||
// parse failure on purpose: guessing at it is how a setting ends up the
|
||||
// opposite of what the operator meant.
|
||||
func (l *loader) boolean(key string, def bool) bool {
|
||||
s, ok := l.raw(key)
|
||||
if !ok {
|
||||
@@ -208,76 +109,10 @@ func (l *loader) boolean(key string, def bool) bool {
|
||||
return b
|
||||
}
|
||||
|
||||
func (l *loader) duration(key string, def time.Duration) time.Duration {
|
||||
s, ok := l.raw(key)
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
|
||||
d, err := time.ParseDuration(s)
|
||||
if err != nil {
|
||||
l.fail(key, s, "not a duration (e.g. 30s, 2m)")
|
||||
|
||||
return def
|
||||
}
|
||||
|
||||
if d < minTimeout || d > maxTimeout {
|
||||
l.fail(key, s, fmt.Sprintf("outside the range %s..%s", minTimeout, maxTimeout))
|
||||
|
||||
return def
|
||||
}
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
// bytesize accepts both a plain integer and a human size ("1MiB",
|
||||
// "512kB"), which is the form an operator actually writes.
|
||||
func (l *loader) bytesize(key string, def, minVal, maxVal int64) int64 {
|
||||
s, ok := l.raw(key)
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
|
||||
n, err := humanize.ParseBytes(s)
|
||||
if err != nil {
|
||||
l.fail(key, s, "not a byte size (e.g. 1048576, 1MiB, 512kB)")
|
||||
|
||||
return def
|
||||
}
|
||||
|
||||
// maxVal and minVal are compile-time constants of this package,
|
||||
// both positive, so these conversions cannot overflow; n is
|
||||
// range-checked before it is narrowed.
|
||||
if n > uint64(maxVal) { //nolint:gosec // see above
|
||||
l.fail(key, s, byteRangeMessage(minVal, maxVal))
|
||||
|
||||
return def
|
||||
}
|
||||
|
||||
sz := int64(n) //nolint:gosec // n was just checked against maxVal, a positive int64.
|
||||
if sz < minVal {
|
||||
l.fail(key, s, byteRangeMessage(minVal, maxVal))
|
||||
|
||||
return def
|
||||
}
|
||||
|
||||
return sz
|
||||
}
|
||||
|
||||
// byteRangeMessage renders the permitted size range the way an operator
|
||||
// wrote the value they got wrong.
|
||||
func byteRangeMessage(minVal, maxVal int64) string {
|
||||
//nolint:gosec // both are positive compile-time constants of this package.
|
||||
return fmt.Sprintf("outside the range %s..%s",
|
||||
humanize.IBytes(uint64(minVal)), humanize.IBytes(uint64(maxVal)))
|
||||
}
|
||||
|
||||
// New parses and validates the environment. Returning an error here
|
||||
// aborts fx startup before anything listens, which is the whole point:
|
||||
// there is no partially configured running state to reason about.
|
||||
//
|
||||
//nolint:revive // lc parameter is required by fx even if unused.
|
||||
func New(lc fx.Lifecycle, _ Params) (*Config, error) {
|
||||
// New parses and validates the environment. An error here aborts
|
||||
// startup before the chat client is launched, so there is no partially
|
||||
// configured running state to reason about.
|
||||
func New() (*Config, error) {
|
||||
v := viper.New()
|
||||
v.AutomaticEnv()
|
||||
|
||||
@@ -289,34 +124,10 @@ func New(lc fx.Lifecycle, _ Params) (*Config, error) {
|
||||
func load(v *viper.Viper) (*Config, error) {
|
||||
l := &loader{v: v}
|
||||
|
||||
c := &Config{}
|
||||
|
||||
c.Port = int(l.integer(EnvPort, DefaultPort, minPort, maxPort))
|
||||
c.DataDir = l.str(EnvDataDir, DefaultDataDir)
|
||||
c.Debug = l.boolean(EnvDebug, false)
|
||||
c.BaseURL = l.str(EnvBaseURL, DefaultBaseURL)
|
||||
|
||||
// HSTS defaults to on unless debugging: pinning a developer's
|
||||
// browser to HTTPS on localhost is a self-inflicted outage that
|
||||
// outlives the process.
|
||||
c.HSTS = l.boolean(EnvHSTS, !c.Debug)
|
||||
|
||||
c.DBPath = l.str(EnvDBPath, filepath.Join(c.DataDir, "simplexcalc.db"))
|
||||
c.MaxRequestBody = l.bytesize(
|
||||
EnvMaxRequestBody, DefaultMaxRequestBody, minRequestBody, maxRequestBody,
|
||||
)
|
||||
c.RequestTimeout = l.duration(EnvRequestTimeout, DefaultRequestTimeout)
|
||||
c.ShutdownGrace = l.duration(EnvShutdownGrace, DefaultShutdownGrace)
|
||||
|
||||
c.SentryDSN = l.str(EnvSentryDSN, "")
|
||||
c.SentryEnvironment = l.str(EnvSentryEnv, DefaultSentryEnv)
|
||||
l.checkSentryDSN(c.SentryDSN)
|
||||
|
||||
c.MetricsUser = l.str(EnvMetricsUser, "")
|
||||
c.MetricsPassword = l.str(EnvMetricsPassword, "")
|
||||
l.checkMetricsAuth(c)
|
||||
|
||||
l.loadCSRFKey(c)
|
||||
c := &Config{
|
||||
DataDir: l.str(EnvDataDir, DefaultDataDir),
|
||||
Debug: l.boolean(EnvDebug, false),
|
||||
}
|
||||
|
||||
if len(l.errs) > 0 {
|
||||
return nil, errors.Join(l.errs...)
|
||||
@@ -324,67 +135,3 @@ func load(v *viper.Viper) (*Config, error) {
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// checkSentryDSN refuses a DSN that is present and not a URL. An empty
|
||||
// DSN disables Sentry and is not an error; a typo'd one that silently
|
||||
// disabled it would be, since the operator would believe errors were
|
||||
// being reported.
|
||||
func (l *loader) checkSentryDSN(dsn string) {
|
||||
if dsn == "" {
|
||||
return
|
||||
}
|
||||
|
||||
u, err := url.Parse(dsn)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
// The DSN embeds a key; report the failure without it.
|
||||
l.errs = append(l.errs, fmt.Errorf(
|
||||
"%w: %s is set but is not a valid DSN URL", ErrInvalidConfig, EnvSentryDSN,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// checkMetricsAuth refuses a half-configured metrics credential. See
|
||||
// the field comment on Config.MetricsUser for why neither resolution
|
||||
// is acceptable.
|
||||
func (l *loader) checkMetricsAuth(c *Config) {
|
||||
switch {
|
||||
case c.MetricsUser == "" && c.MetricsPassword == "":
|
||||
return
|
||||
case c.MetricsUser == "":
|
||||
l.errs = append(l.errs, fmt.Errorf(
|
||||
"%w: %s is set but %s is not; set both or neither",
|
||||
ErrInvalidConfig, EnvMetricsPassword, EnvMetricsUser,
|
||||
))
|
||||
case c.MetricsPassword == "":
|
||||
l.errs = append(l.errs, fmt.Errorf(
|
||||
"%w: %s is set but %s is not; set both or neither",
|
||||
ErrInvalidConfig, EnvMetricsUser, EnvMetricsPassword,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// loadCSRFKey decodes CSRF_KEY, or marks the config for an ephemeral
|
||||
// key. Generating the random key is deferred to the server, so that
|
||||
// this function stays pure and testable.
|
||||
func (l *loader) loadCSRFKey(c *Config) {
|
||||
s, ok := l.raw(EnvCSRFKey)
|
||||
if !ok {
|
||||
c.CSRFKeyEphemeral = true
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
key, err := decodeHex(s)
|
||||
if err != nil {
|
||||
// The value is a secret: say what is wrong with it, never
|
||||
// quote it.
|
||||
l.errs = append(l.errs, fmt.Errorf(
|
||||
"%w: %s is set but is not %d hex characters",
|
||||
ErrInvalidConfig, EnvCSRFKey, csrfKeyHexChars,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
c.CSRFKey = key
|
||||
}
|
||||
|
||||
+37
-198
@@ -2,20 +2,12 @@ package config_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"sneak.berlin/go/simplexcalc/internal/config"
|
||||
)
|
||||
|
||||
// Credentials used by the metrics-auth cases.
|
||||
const (
|
||||
testUser = "scraper"
|
||||
testPass = "hunter2"
|
||||
)
|
||||
|
||||
// env builds a viper instance holding exactly the given keys, so a test
|
||||
// describes one environment without touching the process's.
|
||||
func env(kv map[string]string) *viper.Viper {
|
||||
@@ -33,63 +25,40 @@ func env(kv map[string]string) *viper.Viper {
|
||||
func TestAbsentValuesTakeDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, err := config.Load(env(nil))
|
||||
if err != nil {
|
||||
t.Fatalf("empty environment must be valid, got: %v", err)
|
||||
}
|
||||
|
||||
if c.Port != int(config.DefaultPort) {
|
||||
t.Errorf("Port = %d, want %d", c.Port, config.DefaultPort)
|
||||
}
|
||||
|
||||
if c.MaxRequestBody != config.DefaultMaxRequestBody {
|
||||
t.Errorf("MaxRequestBody = %d, want %d",
|
||||
c.MaxRequestBody, config.DefaultMaxRequestBody)
|
||||
}
|
||||
|
||||
if c.RequestTimeout != config.DefaultRequestTimeout {
|
||||
t.Errorf("RequestTimeout = %s, want %s",
|
||||
c.RequestTimeout, config.DefaultRequestTimeout)
|
||||
}
|
||||
|
||||
if !c.HSTS {
|
||||
t.Error("HSTS must default on when DEBUG is not set")
|
||||
}
|
||||
|
||||
if !c.CSRFKeyEphemeral {
|
||||
t.Error("an absent CSRF_KEY must mark the config for an ephemeral key")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetButUnparseableAborts is the central contract of this package.
|
||||
// Every case is a value an operator plausibly types, and every one of
|
||||
// them must fail startup rather than be replaced by the default.
|
||||
func TestSetButUnparseableAborts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]map[string]string{
|
||||
"port is not a number": {config.EnvPort: "eighty"},
|
||||
"port is zero": {config.EnvPort: "0"},
|
||||
"port is above the range": {config.EnvPort: "70000"},
|
||||
"port is a float": {config.EnvPort: "8080.0"},
|
||||
"debug is yes": {config.EnvDebug: "yes"},
|
||||
"hsts is on": {config.EnvHSTS: "on"},
|
||||
"body cap is nonsense": {config.EnvMaxRequestBody: "big"},
|
||||
"body cap is too large": {config.EnvMaxRequestBody: "1TiB"},
|
||||
"body cap is too small": {config.EnvMaxRequestBody: "10"},
|
||||
"timeout has no unit": {config.EnvRequestTimeout: "30"},
|
||||
"timeout is out of range": {config.EnvRequestTimeout: "1h"},
|
||||
"grace is nonsense": {config.EnvShutdownGrace: "soon"},
|
||||
"sentry dsn is not a url": {config.EnvSentryDSN: "not a dsn"},
|
||||
"csrf key is not hex": {config.EnvCSRFKey: "not-hex-at-all"},
|
||||
"csrf key is wrong length": {config.EnvCSRFKey: "abcdef"},
|
||||
}
|
||||
|
||||
for name, kv := range cases {
|
||||
for name, kv := range map[string]map[string]string{
|
||||
"unset": nil,
|
||||
"whitespace only": {config.EnvDataDir: " ", config.EnvDebug: " "},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, err := config.Load(env(kv))
|
||||
if err != nil {
|
||||
t.Fatalf("absent values must be valid, got: %v", err)
|
||||
}
|
||||
|
||||
if c.DataDir != config.DefaultDataDir {
|
||||
t.Errorf("DataDir = %q, want %q", c.DataDir, config.DefaultDataDir)
|
||||
}
|
||||
|
||||
if c.Debug {
|
||||
t.Error("Debug must default off")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetButUnparseableAborts is the central contract of this package:
|
||||
// a value an operator plausibly types, and that does not parse, fails
|
||||
// startup rather than being replaced by the default.
|
||||
func TestSetButUnparseableAborts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, raw := range []string{"yes", "on", "enabled", "2"} {
|
||||
t.Run(raw, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, err := config.Load(env(map[string]string{config.EnvDebug: raw}))
|
||||
if err == nil {
|
||||
t.Fatalf("wanted a startup failure, got a Config: %+v", c)
|
||||
}
|
||||
@@ -105,153 +74,23 @@ func TestSetButUnparseableAborts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecretsAreNotEchoed: a rejected CSRF key must not appear in the
|
||||
// error, because errors are logged and a log is not a place to put a
|
||||
// key.
|
||||
func TestSecretsAreNotEchoed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const secret = "00112233445566778899aabbccdd" // valid hex, wrong length
|
||||
|
||||
_, err := config.Load(env(map[string]string{config.EnvCSRFKey: secret}))
|
||||
if err == nil {
|
||||
t.Fatal("wanted a failure for a short CSRF key")
|
||||
}
|
||||
|
||||
if strings.Contains(err.Error(), secret) {
|
||||
t.Errorf("the rejected key was echoed in the error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHalfSetMetricsAuthAborts covers the case the issue calls out
|
||||
// explicitly: auth config that is half-set must fail loudly, in both
|
||||
// directions.
|
||||
func TestHalfSetMetricsAuthAborts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]map[string]string{
|
||||
"user without password": {config.EnvMetricsUser: testUser},
|
||||
"password without user": {config.EnvMetricsPassword: testPass},
|
||||
}
|
||||
|
||||
for name, kv := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := config.Load(env(kv))
|
||||
if err == nil {
|
||||
t.Fatal("half-set metrics credentials must abort startup")
|
||||
}
|
||||
|
||||
if !errors.Is(err, config.ErrInvalidConfig) {
|
||||
t.Errorf("error does not wrap ErrInvalidConfig: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
both, err := config.Load(env(map[string]string{
|
||||
config.EnvMetricsUser: testUser, config.EnvMetricsPassword: testPass,
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("both credentials set must be valid, got: %v", err)
|
||||
}
|
||||
|
||||
if both.MetricsUser != testUser || both.MetricsPassword != testPass {
|
||||
t.Error("credentials did not survive parsing")
|
||||
}
|
||||
|
||||
neither, err := config.Load(env(nil))
|
||||
if err != nil {
|
||||
t.Fatalf("neither credential set must be valid, got: %v", err)
|
||||
}
|
||||
|
||||
if neither.MetricsUser != "" || neither.MetricsPassword != "" {
|
||||
t.Error("credentials appeared from nowhere")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEveryFailureIsReported: one restart should surface the whole list,
|
||||
// not just the first problem.
|
||||
func TestEveryFailureIsReported(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := config.Load(env(map[string]string{
|
||||
config.EnvPort: "eighty",
|
||||
config.EnvDebug: "yes",
|
||||
config.EnvRequestTimeout: "soon",
|
||||
}))
|
||||
if err == nil {
|
||||
t.Fatal("wanted failures")
|
||||
}
|
||||
|
||||
for _, key := range []string{
|
||||
config.EnvPort, config.EnvDebug, config.EnvRequestTimeout,
|
||||
} {
|
||||
if !strings.Contains(err.Error(), key) {
|
||||
t.Errorf("%s is broken but is not named in the error: %v", key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidValuesAreUsed proves the parsers accept what they document.
|
||||
func TestValidValuesAreUsed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
|
||||
c, err := config.Load(env(map[string]string{
|
||||
config.EnvPort: "9000",
|
||||
config.EnvDebug: "true",
|
||||
config.EnvMaxRequestBody: "2MiB",
|
||||
config.EnvRequestTimeout: "45s",
|
||||
config.EnvShutdownGrace: "5s",
|
||||
config.EnvDataDir: "/var/lib/example",
|
||||
config.EnvCSRFKey: key,
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("valid environment was rejected: %v", err)
|
||||
}
|
||||
|
||||
if c.Port != 9000 {
|
||||
t.Errorf("Port = %d, want 9000", c.Port)
|
||||
}
|
||||
|
||||
if c.MaxRequestBody != 2<<20 {
|
||||
t.Errorf("MaxRequestBody = %d, want %d", c.MaxRequestBody, 2<<20)
|
||||
}
|
||||
|
||||
if c.RequestTimeout != 45*time.Second {
|
||||
t.Errorf("RequestTimeout = %s, want 45s", c.RequestTimeout)
|
||||
}
|
||||
|
||||
if c.HSTS {
|
||||
t.Error("HSTS must default off when DEBUG is true")
|
||||
}
|
||||
|
||||
if len(c.CSRFKey) != config.CSRFKeyBytes || c.CSRFKeyEphemeral {
|
||||
t.Errorf("CSRFKey not decoded: len=%d ephemeral=%v",
|
||||
len(c.CSRFKey), c.CSRFKeyEphemeral)
|
||||
}
|
||||
|
||||
if c.DBPath != "/var/lib/example/simplexcalc.db" {
|
||||
t.Errorf("DBPath = %q, want it derived from DATA_DIR", c.DBPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExplicitOverridesDerivedDBPath: DB_PATH wins over the DATA_DIR
|
||||
// derivation, which is the only reason it exists.
|
||||
func TestExplicitOverridesDerivedDBPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, err := config.Load(env(map[string]string{
|
||||
config.EnvDataDir: "/var/lib/example",
|
||||
config.EnvDBPath: "/srv/other.db",
|
||||
config.EnvDebug: "true",
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("valid environment was rejected: %v", err)
|
||||
}
|
||||
|
||||
if c.DBPath != "/srv/other.db" {
|
||||
t.Errorf("DBPath = %q, want /srv/other.db", c.DBPath)
|
||||
if c.DataDir != "/var/lib/example" {
|
||||
t.Errorf("DataDir = %q, want /var/lib/example", c.DataDir)
|
||||
}
|
||||
|
||||
if !c.Debug {
|
||||
t.Error("Debug = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,3 @@ package config
|
||||
//
|
||||
//nolint:gochecknoglobals // a test seam, not mutable state.
|
||||
var Load = load
|
||||
|
||||
// CSRFKeyBytes is the required key length, so the tests can assert on
|
||||
// it without restating the number.
|
||||
const CSRFKeyBytes = csrfKeyBytes
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// errKeyLength is returned for a well-formed hex string of the wrong
|
||||
// length, so decodeHex has one error type for both ways of being wrong.
|
||||
var errKeyLength = errors.New("wrong key length")
|
||||
|
||||
// decodeHex decodes exactly csrfKeyBytes bytes of hex. It exists as its
|
||||
// own function so that the length rule and the encoding rule are
|
||||
// enforced in one place, and so that the caller never has to decide
|
||||
// what a short-but-valid key means.
|
||||
func decodeHex(s string) ([]byte, error) {
|
||||
b, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decoding hex: %w", err)
|
||||
}
|
||||
|
||||
if len(b) != csrfKeyBytes {
|
||||
return nil, fmt.Errorf("%w: got %d bytes, want %d",
|
||||
errKeyLength, len(b), csrfKeyBytes)
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
||||
Reference in New Issue
Block a user