All checks were successful
check / check (push) Successful in 1m44s
The stricter canonical .golangci.yml surfaced 81 findings in the config validation code merged from main (#53). Fix them all with no behavior change: static sentinel errors wrapped with %w preserving the existing messages (err113), config key name constants (goconst), t.Parallel() throughout except the Setenv/Chdir test (paralleltest), white-box test renamed to config_validation_internal_test.go (testpackage), case tables extracted into builder functions plus a shared runAbortCases helper (funlen/dupl/gochecknoglobals), plain error assignments (noinlineerr), any instead of interface{} and strings.SplitSeq (modernize), slog.DiscardHandler (sloglint), 88-col wrapping (lll), and removal of two stale nolint:gosec directives (nolintlint).
597 lines
18 KiB
Go
597 lines
18 KiB
Go
package config
|
|
|
|
import (
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.eeqj.de/sneak/smartconfig"
|
|
)
|
|
|
|
// validTestSigningKey is a 32-character signing key that satisfies the
|
|
// minimum length requirement in validate().
|
|
const validTestSigningKey = "0123456789abcdef0123456789abcdef"
|
|
|
|
// signingKeyLine is a valid signing_key config line used as the base of
|
|
// test config files.
|
|
const signingKeyLine = "signing_key: " + validTestSigningKey + "\n"
|
|
|
|
// testHostS3 is an allowlist host entry used across the config tests.
|
|
const testHostS3 = "s3.sneak.cloud"
|
|
|
|
// nullValueText is the substring that error messages about explicitly
|
|
// null config values must contain.
|
|
const nullValueText = "null"
|
|
|
|
// abortCase describes a config file that must abort startup with an
|
|
// error mentioning every string in wantErrSubstrings.
|
|
type abortCase struct {
|
|
name string
|
|
yaml string
|
|
// wantErrSubstrings must all appear in the error message.
|
|
wantErrSubstrings []string
|
|
}
|
|
|
|
// configFromYAML writes yamlContent to a temporary config file, loads it
|
|
// via smartconfig, and constructs a Config from it using the same code
|
|
// path the server uses at startup.
|
|
func configFromYAML(t *testing.T, yamlContent string) (*Config, error) {
|
|
t.Helper()
|
|
|
|
tmpDir := t.TempDir()
|
|
configPath := filepath.Join(tmpDir, "config.yml")
|
|
|
|
err := os.WriteFile(configPath, []byte(yamlContent), 0o600)
|
|
if err != nil {
|
|
t.Fatalf("failed to write test config: %v", err)
|
|
}
|
|
|
|
sc, err := smartconfig.NewFromConfigPath(configPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to load test config: %v", err)
|
|
}
|
|
|
|
return newFromSmartConfig(sc)
|
|
}
|
|
|
|
func TestOmittedValuesUseDefaults(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
c, err := configFromYAML(t, signingKeyLine)
|
|
if err != nil {
|
|
t.Fatalf("minimal config should be valid, got error: %v", err)
|
|
}
|
|
|
|
if c.Port != DefaultPort {
|
|
t.Errorf("Port = %d, want default %d", c.Port, DefaultPort)
|
|
}
|
|
|
|
if c.StateDir != DefaultStateDir {
|
|
t.Errorf("StateDir = %q, want default %q", c.StateDir, DefaultStateDir)
|
|
}
|
|
|
|
if c.UpstreamConnectionsPerHost != DefaultUpstreamConnectionsPerHost {
|
|
t.Errorf("UpstreamConnectionsPerHost = %d, want default %d",
|
|
c.UpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost)
|
|
}
|
|
|
|
if c.Debug {
|
|
t.Error("Debug = true, want default false")
|
|
}
|
|
|
|
if c.MaintenanceMode {
|
|
t.Error("MaintenanceMode = true, want default false")
|
|
}
|
|
|
|
if c.AllowHTTP {
|
|
t.Error("AllowHTTP = true, want default false")
|
|
}
|
|
|
|
if len(c.AllowlistHosts) != 0 {
|
|
t.Errorf("AllowlistHosts = %v, want empty", c.AllowlistHosts)
|
|
}
|
|
|
|
wantDBURL := "file:" + DefaultStateDir + "/state.sqlite3?_journal_mode=WAL"
|
|
if c.DBURL != wantDBURL {
|
|
t.Errorf("DBURL = %q, want derived default %q", c.DBURL, wantDBURL)
|
|
}
|
|
}
|
|
|
|
func TestExplicitValidValuesAreUsed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
yamlContent := `
|
|
port: 9090
|
|
debug: true
|
|
maintenance_mode: true
|
|
state_dir: /tmp/pixa-test-state
|
|
db_url: "file:/tmp/pixa-test-state/other.sqlite3"
|
|
signing_key: ` + validTestSigningKey + `
|
|
allowlist_hosts:
|
|
- s3.sneak.cloud
|
|
- .example.com
|
|
allow_http: true
|
|
upstream_connections_per_host: 5
|
|
sentry_dsn: "https://abc123@sentry.example.com/42"
|
|
metrics:
|
|
username: metricsuser
|
|
password: metricspass
|
|
`
|
|
|
|
c, err := configFromYAML(t, yamlContent)
|
|
if err != nil {
|
|
t.Fatalf("valid config should load, got error: %v", err)
|
|
}
|
|
|
|
if c.Port != 9090 {
|
|
t.Errorf("Port = %d, want 9090", c.Port)
|
|
}
|
|
|
|
if !c.Debug || !c.MaintenanceMode || !c.AllowHTTP {
|
|
t.Errorf("bool fields = debug %v maintenance %v allow_http %v, want all true",
|
|
c.Debug, c.MaintenanceMode, c.AllowHTTP)
|
|
}
|
|
|
|
if c.StateDir != "/tmp/pixa-test-state" {
|
|
t.Errorf("StateDir = %q, want /tmp/pixa-test-state", c.StateDir)
|
|
}
|
|
|
|
if c.DBURL != "file:/tmp/pixa-test-state/other.sqlite3" {
|
|
t.Errorf("DBURL = %q, want explicit value", c.DBURL)
|
|
}
|
|
|
|
if len(c.AllowlistHosts) != 2 || c.AllowlistHosts[0] != testHostS3 ||
|
|
c.AllowlistHosts[1] != ".example.com" {
|
|
t.Errorf("AllowlistHosts = %v, want [s3.sneak.cloud .example.com]",
|
|
c.AllowlistHosts)
|
|
}
|
|
|
|
if c.UpstreamConnectionsPerHost != 5 {
|
|
t.Errorf("UpstreamConnectionsPerHost = %d, want 5", c.UpstreamConnectionsPerHost)
|
|
}
|
|
|
|
if c.SentryDSN != "https://abc123@sentry.example.com/42" {
|
|
t.Errorf("SentryDSN = %q, want explicit value", c.SentryDSN)
|
|
}
|
|
|
|
if c.MetricsUsername != "metricsuser" || c.MetricsPassword != "metricspass" {
|
|
t.Errorf("metrics = %q/%q, want metricsuser/metricspass",
|
|
c.MetricsUsername, c.MetricsPassword)
|
|
}
|
|
}
|
|
|
|
func TestCommaSeparatedAllowlistStillSupported(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
yamlContent := signingKeyLine +
|
|
`allowlist_hosts: "s3.sneak.cloud, sneak.berlin"
|
|
`
|
|
|
|
c, err := configFromYAML(t, yamlContent)
|
|
if err != nil {
|
|
t.Fatalf("comma-separated allowlist should load, got error: %v", err)
|
|
}
|
|
|
|
if len(c.AllowlistHosts) != 2 || c.AllowlistHosts[0] != testHostS3 ||
|
|
c.AllowlistHosts[1] != "sneak.berlin" {
|
|
t.Errorf("AllowlistHosts = %v, want [s3.sneak.cloud sneak.berlin]",
|
|
c.AllowlistHosts)
|
|
}
|
|
}
|
|
|
|
// runAbortCases asserts that each case's config aborts startup with an
|
|
// error message mentioning every expected substring.
|
|
func runAbortCases(t *testing.T, cases []abortCase) {
|
|
t.Helper()
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
c, err := configFromYAML(t, tc.yaml)
|
|
if err == nil {
|
|
t.Fatalf("config with %s must abort startup, got config: %+v", tc.name, c)
|
|
}
|
|
|
|
t.Logf("got expected error: %v", err)
|
|
|
|
for _, want := range tc.wantErrSubstrings {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Errorf("error %q does not mention %q", err.Error(), want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// invalidScalarValueCases are configs where a scalar key is explicitly
|
|
// set to an unparseable or out-of-range value; each must abort startup
|
|
// naming the offending key, never silently fall back to the default.
|
|
func invalidScalarValueCases() []abortCase {
|
|
return []abortCase{
|
|
{
|
|
name: "port not a number",
|
|
yaml: signingKeyLine + "port: banana\n",
|
|
wantErrSubstrings: []string{keyPort, "banana"},
|
|
},
|
|
{
|
|
name: "port zero",
|
|
yaml: signingKeyLine + "port: 0\n",
|
|
wantErrSubstrings: []string{keyPort, "0"},
|
|
},
|
|
{
|
|
name: "port above 65535",
|
|
yaml: signingKeyLine + "port: 99999\n",
|
|
wantErrSubstrings: []string{keyPort, "99999"},
|
|
},
|
|
{
|
|
name: "port fractional",
|
|
yaml: signingKeyLine + "port: 8080.5\n",
|
|
wantErrSubstrings: []string{keyPort, "8080.5"},
|
|
},
|
|
{
|
|
name: "debug not a bool",
|
|
yaml: signingKeyLine + "debug: notabool\n",
|
|
wantErrSubstrings: []string{keyDebug, "notabool"},
|
|
},
|
|
{
|
|
name: "maintenance_mode not a bool",
|
|
yaml: signingKeyLine + "maintenance_mode: sometimes\n",
|
|
wantErrSubstrings: []string{keyMaintenanceMode, "sometimes"},
|
|
},
|
|
{
|
|
name: "allow_http numeric",
|
|
yaml: signingKeyLine + "allow_http: 2\n",
|
|
wantErrSubstrings: []string{keyAllowHTTP, "2"},
|
|
},
|
|
{
|
|
name: "upstream_connections_per_host zero",
|
|
yaml: signingKeyLine + "upstream_connections_per_host: 0\n",
|
|
wantErrSubstrings: []string{keyUpstreamConnectionsPerHost, "0"},
|
|
},
|
|
{
|
|
name: "upstream_connections_per_host negative",
|
|
yaml: signingKeyLine + "upstream_connections_per_host: -3\n",
|
|
wantErrSubstrings: []string{keyUpstreamConnectionsPerHost, "-3"},
|
|
},
|
|
{
|
|
name: "upstream_connections_per_host not a number",
|
|
yaml: signingKeyLine + "upstream_connections_per_host: many\n",
|
|
wantErrSubstrings: []string{keyUpstreamConnectionsPerHost, "many"},
|
|
},
|
|
}
|
|
}
|
|
|
|
// invalidHostAndCredentialCases are configs where allowlist_hosts,
|
|
// signing_key, state_dir, sentry_dsn, or metrics is explicitly set to
|
|
// an invalid value; each must abort startup naming the offending key.
|
|
func invalidHostAndCredentialCases() []abortCase {
|
|
return []abortCase{
|
|
{
|
|
name: "allowlist host with scheme",
|
|
yaml: signingKeyLine + "allowlist_hosts:\n - https://example.com\n",
|
|
wantErrSubstrings: []string{
|
|
keyAllowlistHosts, "https://example.com",
|
|
},
|
|
},
|
|
{
|
|
name: "allowlist host with path",
|
|
yaml: signingKeyLine + "allowlist_hosts:\n - example.com/images\n",
|
|
wantErrSubstrings: []string{
|
|
keyAllowlistHosts, "example.com/images",
|
|
},
|
|
},
|
|
{
|
|
name: "allowlist host with whitespace",
|
|
yaml: signingKeyLine + "allowlist_hosts:\n - \"exa mple.com\"\n",
|
|
wantErrSubstrings: []string{keyAllowlistHosts, "exa mple.com"},
|
|
},
|
|
{
|
|
name: "allowlist entry not a string",
|
|
yaml: signingKeyLine + "allowlist_hosts:\n - 123\n",
|
|
wantErrSubstrings: []string{keyAllowlistHosts, "123"},
|
|
},
|
|
{
|
|
name: "allowlist not a list",
|
|
yaml: signingKeyLine + "allowlist_hosts:\n key: value\n",
|
|
wantErrSubstrings: []string{keyAllowlistHosts},
|
|
},
|
|
{
|
|
name: "signing_key too short",
|
|
yaml: "signing_key: short\n",
|
|
wantErrSubstrings: []string{keySigningKey},
|
|
},
|
|
{
|
|
name: "signing_key missing",
|
|
yaml: "port: 8080\n",
|
|
wantErrSubstrings: []string{keySigningKey},
|
|
},
|
|
{
|
|
name: "state_dir explicitly empty",
|
|
yaml: signingKeyLine + "state_dir: \"\"\n",
|
|
wantErrSubstrings: []string{keyStateDir},
|
|
},
|
|
{
|
|
name: "sentry_dsn not a URL",
|
|
yaml: signingKeyLine + "sentry_dsn: \"not a url\"\n",
|
|
wantErrSubstrings: []string{keySentryDSN, "not a url"},
|
|
},
|
|
{
|
|
name: "metrics username without password",
|
|
yaml: signingKeyLine + "metrics:\n username: bob\n",
|
|
wantErrSubstrings: []string{keyMetrics},
|
|
},
|
|
{
|
|
name: "metrics password without username",
|
|
yaml: signingKeyLine + "metrics:\n password: hunter2\n",
|
|
wantErrSubstrings: []string{keyMetrics},
|
|
},
|
|
}
|
|
}
|
|
|
|
// TestSetButInvalidValueAbortsStartup verifies the no-silent-fallback
|
|
// rule: a key that is explicitly set to an unparseable or out-of-range
|
|
// value must produce a startup error naming the offending key, never
|
|
// silently fall back to the default.
|
|
func TestSetButInvalidValueAbortsStartup(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runAbortCases(t, append(
|
|
invalidScalarValueCases(), invalidHostAndCredentialCases()...))
|
|
}
|
|
|
|
// explicitNullValueCases are configs where a key is explicitly set to
|
|
// null (including the bare "key:" form and the "~" alias); each must
|
|
// abort startup naming the key.
|
|
func explicitNullValueCases() []abortCase {
|
|
return []abortCase{
|
|
{
|
|
name: "port explicit null",
|
|
yaml: signingKeyLine + "port: null\n",
|
|
wantErrSubstrings: []string{keyPort, nullValueText},
|
|
},
|
|
{
|
|
name: "port bare key no value",
|
|
yaml: signingKeyLine + "port:\n",
|
|
wantErrSubstrings: []string{keyPort, nullValueText},
|
|
},
|
|
{
|
|
name: "debug tilde null",
|
|
yaml: signingKeyLine + "debug: ~\n",
|
|
wantErrSubstrings: []string{keyDebug, nullValueText},
|
|
},
|
|
{
|
|
name: "maintenance_mode null",
|
|
yaml: signingKeyLine + "maintenance_mode: null\n",
|
|
wantErrSubstrings: []string{keyMaintenanceMode, nullValueText},
|
|
},
|
|
{
|
|
name: "allow_http null",
|
|
yaml: signingKeyLine + "allow_http: null\n",
|
|
wantErrSubstrings: []string{keyAllowHTTP, nullValueText},
|
|
},
|
|
{
|
|
name: "state_dir null",
|
|
yaml: signingKeyLine + "state_dir: null\n",
|
|
wantErrSubstrings: []string{keyStateDir, nullValueText},
|
|
},
|
|
{
|
|
name: "db_url null",
|
|
yaml: signingKeyLine + "db_url: null\n",
|
|
wantErrSubstrings: []string{keyDBURL, nullValueText},
|
|
},
|
|
{
|
|
name: "sentry_dsn null",
|
|
yaml: signingKeyLine + "sentry_dsn: null\n",
|
|
wantErrSubstrings: []string{keySentryDSN, nullValueText},
|
|
},
|
|
{
|
|
name: "upstream_connections_per_host null",
|
|
yaml: signingKeyLine + "upstream_connections_per_host: null\n",
|
|
wantErrSubstrings: []string{keyUpstreamConnectionsPerHost, nullValueText},
|
|
},
|
|
{
|
|
name: "allowlist_hosts null",
|
|
yaml: signingKeyLine + "allowlist_hosts: null\n",
|
|
wantErrSubstrings: []string{keyAllowlistHosts, nullValueText},
|
|
},
|
|
{
|
|
name: "signing_key null",
|
|
yaml: "signing_key: null\n",
|
|
wantErrSubstrings: []string{keySigningKey, nullValueText},
|
|
},
|
|
{
|
|
name: "metrics null",
|
|
yaml: signingKeyLine + "metrics: null\n",
|
|
wantErrSubstrings: []string{keyMetrics, nullValueText},
|
|
},
|
|
{
|
|
name: "metrics subkeys null",
|
|
yaml: signingKeyLine + "metrics:\n username: null\n password: null\n",
|
|
wantErrSubstrings: []string{
|
|
keyMetricsUsername, keyMetricsPassword, nullValueText,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// TestExplicitNullValueAbortsStartup verifies that a key explicitly
|
|
// set to null (including the bare "key:" form and the "~" alias) aborts
|
|
// startup naming the key. An explicit null is a SET value: it must
|
|
// never silently fall back to the default the way an omitted key does.
|
|
func TestExplicitNullValueAbortsStartup(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runAbortCases(t, explicitNullValueCases())
|
|
}
|
|
|
|
// TestExplicitlyEmptyDBURLAbortsStartup verifies that db_url set to an
|
|
// empty string aborts startup: the derived file:...state.sqlite3 URL is
|
|
// a default, and defaults apply only to omitted keys. This matches
|
|
// state_dir, where an explicitly empty value already aborts.
|
|
func TestExplicitlyEmptyDBURLAbortsStartup(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
yamlContent := signingKeyLine + "db_url: \"\"\n"
|
|
|
|
c, err := configFromYAML(t, yamlContent)
|
|
if err == nil {
|
|
t.Fatalf("explicitly empty db_url must abort startup, got config: %+v", c)
|
|
}
|
|
|
|
t.Logf("got expected error: %v", err)
|
|
|
|
if !strings.Contains(err.Error(), keyDBURL) {
|
|
t.Errorf("error %q does not name the offending key db_url", err.Error())
|
|
}
|
|
}
|
|
|
|
// TestAllowlistHostsRejectsDotOnlyEntries verifies that entries with no
|
|
// hostname labels are rejected. The allowlist matcher treats a leading
|
|
// dot as a suffix pattern, so a bare "." entry would match any upstream
|
|
// host written in FQDN trailing-dot form (e.g. evil.com.) and
|
|
// effectively disable URL signing with a single character.
|
|
func TestAllowlistHostsRejectsDotOnlyEntries(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, entry := range []string{".", ".."} {
|
|
t.Run(entry, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
yamlContent := signingKeyLine +
|
|
"allowlist_hosts:\n - \"" + entry + "\"\n"
|
|
|
|
c, err := configFromYAML(t, yamlContent)
|
|
if err == nil {
|
|
t.Fatalf("allowlist entry %q must abort startup, got config: %+v",
|
|
entry, c)
|
|
}
|
|
|
|
t.Logf("got expected error: %v", err)
|
|
|
|
if !strings.Contains(err.Error(), keyAllowlistHosts) {
|
|
t.Errorf("error %q does not name the offending key allowlist_hosts",
|
|
err.Error())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUnknownTopLevelKeyAbortsStartup(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
yamlContent := signingKeyLine + `whitelist_hosts:
|
|
- example.com
|
|
`
|
|
|
|
c, err := configFromYAML(t, yamlContent)
|
|
if err == nil {
|
|
t.Fatalf("config with unknown key must abort startup, got config: %+v", c)
|
|
}
|
|
|
|
t.Logf("got expected error: %v", err)
|
|
|
|
if !strings.Contains(err.Error(), "whitelist_hosts") {
|
|
t.Errorf("error %q does not name the unknown key whitelist_hosts", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestUnknownMetricsSubkeyAbortsStartup(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
yamlContent := signingKeyLine + `metrics:
|
|
username: bob
|
|
password: hunter2
|
|
port: 9100
|
|
`
|
|
|
|
c, err := configFromYAML(t, yamlContent)
|
|
if err == nil {
|
|
t.Fatalf("config with unknown metrics subkey must abort startup, got config: %+v", c)
|
|
}
|
|
|
|
t.Logf("got expected error: %v", err)
|
|
|
|
if !strings.Contains(err.Error(), "metrics.port") {
|
|
t.Errorf("error %q does not name the unknown key metrics.port", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestEnvSectionIsPermitted(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
yamlContent := signingKeyLine + `env:
|
|
PIXA_TEST_ENV_INJECTION: injected
|
|
`
|
|
|
|
_, err := configFromYAML(t, yamlContent)
|
|
if err != nil {
|
|
t.Fatalf(
|
|
"env section must be permitted (smartconfig consumes it), got error: %v",
|
|
err)
|
|
}
|
|
}
|
|
|
|
func TestMalformedConfigFileAbortsStartup(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
configPath := filepath.Join(tmpDir, "config.yml")
|
|
|
|
err := os.WriteFile(configPath, []byte("port: [unclosed\n"), 0o600)
|
|
if err != nil {
|
|
t.Fatalf("failed to write malformed config: %v", err)
|
|
}
|
|
|
|
// loadConfigFile falls through to the relative config.yml candidate;
|
|
// the appname is chosen so no /etc or $HOME candidate can exist.
|
|
t.Setenv("PIXA_CONFIG_PATH", "")
|
|
t.Chdir(tmpDir)
|
|
|
|
log := slog.New(slog.DiscardHandler)
|
|
|
|
sc, err := loadConfigFile(log, "pixa-test-nonexistent-app")
|
|
if err == nil {
|
|
t.Fatalf("malformed config file must abort startup, got config: %v", sc)
|
|
}
|
|
|
|
t.Logf("got expected error: %v", err)
|
|
}
|
|
|
|
func TestEnsureStateDirCreatesDirectory(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stateDir := filepath.Join(t.TempDir(), "nested", "state")
|
|
|
|
c := &Config{StateDir: stateDir}
|
|
|
|
err := c.ensureStateDirWritable()
|
|
if err != nil {
|
|
t.Fatalf("creatable state_dir must validate, got error: %v", err)
|
|
}
|
|
|
|
info, err := os.Stat(stateDir)
|
|
if err != nil || !info.IsDir() {
|
|
t.Fatalf("state_dir was not created: info=%v err=%v", info, err)
|
|
}
|
|
}
|
|
|
|
func TestEnsureStateDirFailsOnUncreatablePath(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A path below /dev/null can never be created, even when running
|
|
// as root (as in the Docker build).
|
|
c := &Config{StateDir: "/dev/null/pixa-state"}
|
|
|
|
err := c.ensureStateDirWritable()
|
|
if err == nil {
|
|
t.Fatal("uncreatable state_dir must abort startup, got nil error")
|
|
}
|
|
|
|
t.Logf("got expected error: %v", err)
|
|
|
|
if !strings.Contains(err.Error(), keyStateDir) {
|
|
t.Errorf("error %q does not name the offending key state_dir", err.Error())
|
|
}
|
|
}
|