package config import ( "errors" "fmt" "net/netip" "net/url" "os" "path/filepath" "reflect" "slices" "strings" "testing" "time" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid" ) // writeVAPIDKey writes a new VAPID key pair's private key to dir/name, as // cmd/vapidgen tells the operator to, and returns the pair. func writeVAPIDKey(t *testing.T, dir, name string) (vapid.KeyPair, string) { t.Helper() pair, err := vapid.Generate() if err != nil { t.Fatal(err) } path := filepath.Join(dir, name) if err := os.WriteFile(path, []byte(pair.PrivateKey+"\n"), 0o600); err != nil { t.Fatal(err) } return pair, path } func TestLoad(t *testing.T) { t.Parallel() dir := t.TempDir() secretPath := filepath.Join(dir, "secret_key") if err := os.WriteFile(secretPath, []byte("s3cret-value\n"), 0o600); err != nil { t.Fatal(err) } unreadablePath := filepath.Join(dir, "missing_secret") clientSecretPath := filepath.Join(dir, "oidc_client_secret") if err := os.WriteFile(clientSecretPath, []byte("client-s3cret\n"), 0o600); err != nil { t.Fatal(err) } googleSecretPath := filepath.Join(dir, "google_client_secret") if err := os.WriteFile(googleSecretPath, []byte("google-s3cret\n"), 0o600); err != nil { t.Fatal(err) } emptyPath := filepath.Join(dir, "empty_secret") if err := os.WriteFile(emptyPath, []byte("\n"), 0o600); err != nil { t.Fatal(err) } vapidPair, vapidKeyPath := writeVAPIDKey(t, dir, "vapid_private_key") otherPair, otherKeyPath := writeVAPIDKey(t, dir, "other_vapid_private_key") pemKeyPath := filepath.Join(dir, "vapid_private_key.pem") if err := os.WriteFile(pemKeyPath, []byte("-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIA==\n-----END EC PRIVATE KEY-----\n"), 0o600); err != nil { t.Fatal(err) } paddedKeyPath := filepath.Join(dir, "padded_vapid_private_key") if err := os.WriteFile(paddedKeyPath, []byte(vapidPair.PrivateKey+"=\n"), 0o600); err != nil { t.Fatal(err) } valid := func() map[string]string { return map[string]string{ EnvHTTPAddr: ":8080", EnvPublicURL: "https://meals.example.com", EnvDataDir: dir, EnvShutdownTimeout: "15s", EnvSecretKeyFile: secretPath, EnvOIDCIssuer: "https://auth.example.com/application/o/mealquest/", EnvOIDCClientID: "mealquest", EnvOIDCClientSecretFile: clientSecretPath, EnvOIDCAllowedGroups: "mealquest-admin", EnvGoogleClientID: "mealquest.apps.googleusercontent.com", EnvGoogleClientSecretFile: googleSecretPath, EnvVAPIDPublicKey: vapidPair.PublicKey, EnvVAPIDPrivateKeyFile: vapidKeyPath, EnvVAPIDSubject: "mailto:owner@example.com", } } with := func(changes map[string]string, unset ...string) map[string]string { env := valid() for k, v := range changes { env[k] = v } for _, k := range unset { delete(env, k) } return env } tests := []struct { name string env map[string]string badVars []string // variables the error must name; nil means success }{ { name: "all required vars present", env: valid(), }, { name: "default time zone set", env: with(map[string]string{EnvDefaultTZ: "America/New_York"}), }, { // Validated only when seeding the household settings, so a // stale value cannot stop a boot that no longer uses it. name: "default time zone not an IANA zone", env: with(map[string]string{EnvDefaultTZ: "Mars/Olympus_Mons"}), }, { name: "one missing", env: with(nil, EnvHTTPAddr), badVars: []string{EnvHTTPAddr}, }, { name: "empty value counts as missing", env: with(map[string]string{EnvDataDir: ""}), badVars: []string{EnvDataDir}, }, { name: "all missing are collected", env: map[string]string{}, badVars: []string{ EnvHTTPAddr, EnvPublicURL, EnvDataDir, EnvShutdownTimeout, EnvSecretKeyFile, EnvOIDCIssuer, EnvOIDCClientID, EnvOIDCClientSecretFile, EnvOIDCAllowedGroups, EnvGoogleClientID, EnvGoogleClientSecretFile, EnvVAPIDPublicKey, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject, }, }, { name: "VAPID public key missing", env: with(nil, EnvVAPIDPublicKey), badVars: []string{EnvVAPIDPublicKey}, }, { name: "VAPID private key file missing", env: with(nil, EnvVAPIDPrivateKeyFile), badVars: []string{EnvVAPIDPrivateKeyFile}, }, { name: "VAPID subject missing", env: with(nil, EnvVAPIDSubject), badVars: []string{EnvVAPIDSubject}, }, { name: "only the VAPID public key set", env: with(nil, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject), badVars: []string{EnvVAPIDPrivateKeyFile, EnvVAPIDSubject}, }, { name: "no VAPID variable set", env: with(nil, EnvVAPIDPublicKey, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject), badVars: []string{EnvVAPIDPublicKey, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject}, }, { name: "VAPID private key file unreadable", env: with(map[string]string{EnvVAPIDPrivateKeyFile: unreadablePath}), badVars: []string{EnvVAPIDPrivateKeyFile}, }, { name: "VAPID private key file empty", env: with(map[string]string{EnvVAPIDPrivateKeyFile: emptyPath}), badVars: []string{EnvVAPIDPrivateKeyFile}, }, { name: "inline VAPID private key rejected", env: with(map[string]string{"MEALQUEST_VAPID_PRIVATE_KEY": vapidPair.PrivateKey}), badVars: []string{"MEALQUEST_VAPID_PRIVATE_KEY"}, }, { name: "VAPID private key as PEM", env: with(map[string]string{EnvVAPIDPrivateKeyFile: pemKeyPath}), badVars: []string{EnvVAPIDPrivateKeyFile}, }, { name: "padded VAPID private key", env: with(map[string]string{EnvVAPIDPrivateKeyFile: paddedKeyPath}), badVars: []string{EnvVAPIDPrivateKeyFile}, }, { name: "VAPID private key from another pair", env: with(map[string]string{EnvVAPIDPrivateKeyFile: otherKeyPath}), badVars: []string{EnvVAPIDPrivateKeyFile}, }, { name: "VAPID public key from another pair", env: with(map[string]string{EnvVAPIDPublicKey: otherPair.PublicKey}), badVars: []string{EnvVAPIDPrivateKeyFile}, }, { name: "VAPID public key malformed", env: with(map[string]string{EnvVAPIDPublicKey: "not-a-key"}), badVars: []string{EnvVAPIDPublicKey}, }, { name: "VAPID public key padded", env: with(map[string]string{EnvVAPIDPublicKey: vapidPair.PublicKey + "="}), badVars: []string{EnvVAPIDPublicKey}, }, { name: "VAPID private key given as the public key", env: with(map[string]string{EnvVAPIDPublicKey: vapidPair.PrivateKey}), badVars: []string{EnvVAPIDPublicKey}, }, { name: "VAPID https subject", env: with(map[string]string{EnvVAPIDSubject: "https://meals.example.com/contact"}), }, { name: "VAPID subject without mailto:", env: with(map[string]string{EnvVAPIDSubject: "owner@example.com"}), badVars: []string{EnvVAPIDSubject}, }, { name: "VAPID mailto: subject without an address", env: with(map[string]string{EnvVAPIDSubject: "mailto:owner"}), badVars: []string{EnvVAPIDSubject}, }, { name: "VAPID mailto: subject with a display name", env: with(map[string]string{EnvVAPIDSubject: "mailto:Owner "}), badVars: []string{EnvVAPIDSubject}, }, { name: "VAPID plain http subject", env: with(map[string]string{EnvVAPIDSubject: "http://meals.example.com"}), badVars: []string{EnvVAPIDSubject}, }, { name: "invalid duration", env: with(map[string]string{EnvShutdownTimeout: "ten seconds"}), badVars: []string{EnvShutdownTimeout}, }, { name: "non-positive duration", env: with(map[string]string{EnvShutdownTimeout: "0s"}), badVars: []string{EnvShutdownTimeout}, }, { name: "invalid listen address", env: with(map[string]string{EnvHTTPAddr: "8080"}), badVars: []string{EnvHTTPAddr}, }, { name: "non-numeric port", env: with(map[string]string{EnvHTTPAddr: ":notaport"}), badVars: []string{EnvHTTPAddr}, }, { name: "empty port", env: with(map[string]string{EnvHTTPAddr: "127.0.0.1:"}), badVars: []string{EnvHTTPAddr}, }, { name: "port out of range", env: with(map[string]string{EnvHTTPAddr: ":65536"}), badVars: []string{EnvHTTPAddr}, }, { name: "listen address with IP host", env: with(map[string]string{EnvHTTPAddr: "127.0.0.1:8080"}), }, { name: "listen address with IPv6 host", env: with(map[string]string{EnvHTTPAddr: "[::1]:8080"}), }, { name: "listen address with hostname", env: with(map[string]string{EnvHTTPAddr: "localhost:8080"}), }, { name: "listen host with space", env: with(map[string]string{EnvHTTPAddr: "bad host:8080"}), badVars: []string{EnvHTTPAddr}, }, { name: "listen host with leading hyphen label", env: with(map[string]string{EnvHTTPAddr: "-bad.example.com:8080"}), badVars: []string{EnvHTTPAddr}, }, { name: "listen host with empty label", env: with(map[string]string{EnvHTTPAddr: "bad..example.com:8080"}), badVars: []string{EnvHTTPAddr}, }, { name: "listen host with label over 63 chars", env: with(map[string]string{EnvHTTPAddr: strings.Repeat("a", 64) + ".example.com:8080"}), badVars: []string{EnvHTTPAddr}, }, { name: "public URL without hostname", env: with(map[string]string{EnvPublicURL: "http://:8080"}), badVars: []string{EnvPublicURL}, }, { name: "plain HTTP public URL on a remote host", env: with(map[string]string{EnvPublicURL: "http://meals.example.com"}), badVars: []string{EnvPublicURL}, }, { name: "plain HTTP public URL on a non-loopback IP", env: with(map[string]string{EnvPublicURL: "http://192.168.1.20:8080"}), badVars: []string{EnvPublicURL}, }, { name: "plain HTTP public URL on 127.0.0.1", env: with(map[string]string{EnvPublicURL: "http://127.0.0.1:8080"}), }, { name: "plain HTTP public URL on [::1]", env: with(map[string]string{EnvPublicURL: "http://[::1]:8080"}), }, { name: "relative public URL", env: with(map[string]string{EnvPublicURL: "meals.example.com"}), badVars: []string{EnvPublicURL}, }, { name: "data dir does not exist", env: with(map[string]string{EnvDataDir: filepath.Join(dir, "nope")}), badVars: []string{EnvDataDir}, }, { name: "unreadable secret file", env: with(map[string]string{EnvSecretKeyFile: unreadablePath}), badVars: []string{EnvSecretKeyFile}, }, { name: "secret file path is a directory", env: with(map[string]string{EnvSecretKeyFile: dir}), badVars: []string{EnvSecretKeyFile}, }, { name: "empty secret file", env: with(map[string]string{EnvSecretKeyFile: emptyPath}), badVars: []string{EnvSecretKeyFile}, }, { name: "inline secret rejected", env: with(map[string]string{"MEALQUEST_SECRET_KEY": "inline"}), badVars: []string{"MEALQUEST_SECRET_KEY"}, }, { name: "Google client ID missing", env: with(nil, EnvGoogleClientID), badVars: []string{EnvGoogleClientID}, }, { name: "Google client secret file missing", env: with(nil, EnvGoogleClientSecretFile), badVars: []string{EnvGoogleClientSecretFile}, }, { name: "Google client secret file unreadable", env: with(map[string]string{EnvGoogleClientSecretFile: unreadablePath}), badVars: []string{EnvGoogleClientSecretFile}, }, { name: "Google client secret file empty", env: with(map[string]string{EnvGoogleClientSecretFile: emptyPath}), badVars: []string{EnvGoogleClientSecretFile}, }, { name: "inline Google client secret rejected", env: with(map[string]string{"MEALQUEST_GOOGLE_CLIENT_SECRET": "inline"}), badVars: []string{"MEALQUEST_GOOGLE_CLIENT_SECRET"}, }, { name: "inline OIDC client secret rejected", env: with(map[string]string{"MEALQUEST_OIDC_CLIENT_SECRET": "inline"}), badVars: []string{"MEALQUEST_OIDC_CLIENT_SECRET"}, }, { name: "unreadable OIDC client secret file", env: with(map[string]string{EnvOIDCClientSecretFile: unreadablePath}), badVars: []string{EnvOIDCClientSecretFile}, }, { name: "relative OIDC issuer", env: with(map[string]string{EnvOIDCIssuer: "auth.example.com"}), badVars: []string{EnvOIDCIssuer}, }, { name: "non-http OIDC issuer", env: with(map[string]string{EnvOIDCIssuer: "ftp://auth.example.com"}), badVars: []string{EnvOIDCIssuer}, }, { name: "plain HTTP OIDC issuer on a remote host", env: with(map[string]string{EnvOIDCIssuer: "http://auth.example.com/application/o/mealquest/"}), badVars: []string{EnvOIDCIssuer}, }, { name: "plain HTTP OIDC issuer on localhost", env: with(map[string]string{EnvOIDCIssuer: "http://localhost:9000/application/o/mealquest/"}), }, { name: "several allowed groups", env: with(map[string]string{EnvOIDCAllowedGroups: "mealquest-admin, family"}), }, { name: "empty allowed group entry", env: with(map[string]string{EnvOIDCAllowedGroups: "mealquest-admin,,family"}), badVars: []string{EnvOIDCAllowedGroups}, }, { name: "trailing comma in allowed groups", env: with(map[string]string{EnvOIDCAllowedGroups: "mealquest-admin,"}), badVars: []string{EnvOIDCAllowedGroups}, }, { name: "blank OIDC client ID", env: with(map[string]string{EnvOIDCClientID: " "}), badVars: []string{EnvOIDCClientID}, }, { name: "session lifetimes set", env: with(map[string]string{EnvSessionIdle: "24h", EnvSessionMax: "48h"}), }, { name: "session max equal to idle", env: with(map[string]string{EnvSessionIdle: "24h", EnvSessionMax: "24h"}), }, { name: "invalid session idle", env: with(map[string]string{EnvSessionIdle: "a month"}), badVars: []string{EnvSessionIdle}, }, { name: "empty session idle", env: with(map[string]string{EnvSessionIdle: ""}), badVars: []string{EnvSessionIdle}, }, { name: "non-positive session max", env: with(map[string]string{EnvSessionMax: "-1h"}), badVars: []string{EnvSessionMax}, }, { name: "whole-millisecond session lifetimes", env: with(map[string]string{EnvSessionIdle: "1ms", EnvSessionMax: "2ms"}), }, { name: "sub-millisecond session idle", env: with(map[string]string{EnvSessionIdle: "1500us", EnvSessionMax: "2ms"}), badVars: []string{EnvSessionIdle}, }, { name: "sub-millisecond session max", env: with(map[string]string{EnvSessionIdle: "1ms", EnvSessionMax: "1ms500us"}), badVars: []string{EnvSessionMax}, }, { name: "session max below idle", env: with(map[string]string{EnvSessionIdle: "48h", EnvSessionMax: "24h"}), badVars: []string{EnvSessionMax}, }, { name: "session max below default idle", env: with(map[string]string{EnvSessionMax: "719h"}), badVars: []string{EnvSessionMax}, }, { name: "trusted proxy IP", env: with(map[string]string{EnvTrustedProxy: "10.60.1.33"}), }, { name: "trusted proxy IPs and CIDRs", env: with(map[string]string{EnvTrustedProxy: "10.60.1.33, 172.16.0.0/12,2001:db8::/32,::1"}), }, { name: "blank trusted proxy trusts no peer", env: with(map[string]string{EnvTrustedProxy: " "}), }, { name: "trusted proxy not an IP", env: with(map[string]string{EnvTrustedProxy: "traefik"}), badVars: []string{EnvTrustedProxy}, }, { name: "trusted proxy invalid CIDR", env: with(map[string]string{EnvTrustedProxy: "10.60.1.0/33"}), badVars: []string{EnvTrustedProxy}, }, { name: "trusted proxy CIDR with host bits", env: with(map[string]string{EnvTrustedProxy: "10.60.1.33/24"}), badVars: []string{EnvTrustedProxy}, }, { name: "trusted proxy empty entry", env: with(map[string]string{EnvTrustedProxy: "10.60.1.33,,10.60.1.34"}), badVars: []string{EnvTrustedProxy}, }, { name: "trusted proxy trusting everyone", env: with(map[string]string{EnvTrustedProxy: "0.0.0.0/0"}), badVars: []string{EnvTrustedProxy}, }, { name: "trusted proxy with port", env: with(map[string]string{EnvTrustedProxy: "10.60.1.33:443"}), badVars: []string{EnvTrustedProxy}, }, { name: "several invalid values are all named", env: with(map[string]string{ EnvShutdownTimeout: "soon", EnvSecretKeyFile: unreadablePath, }, EnvPublicURL), badVars: []string{EnvPublicURL, EnvShutdownTimeout, EnvSecretKeyFile}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() lookup := func(k string) (string, bool) { v, ok := tc.env[k] return v, ok } cfg, err := load(lookup) if tc.badVars == nil { if err != nil { t.Fatalf("load() error = %v, want nil", err) } return } var cerr *Error if !errors.As(err, &cerr) { t.Fatalf("load() error = %v, want *Error", err) } var got []string for _, p := range cerr.Problems { got = append(got, p.Var) } if !slices.Equal(got, tc.badVars) { t.Errorf("problem vars = %v, want %v", got, tc.badVars) } for _, v := range tc.badVars { if !strings.Contains(err.Error(), v) { t.Errorf("error message %q does not name %s", err.Error(), v) } } if !reflect.ValueOf(cfg).IsZero() { t.Errorf("load() returned non-zero Config alongside error: %+v", cfg) } }) } } func TestLoadValues(t *testing.T) { t.Parallel() dir := t.TempDir() secretPath := filepath.Join(dir, "secret_key") if err := os.WriteFile(secretPath, []byte("s3cret-value\r\n"), 0o600); err != nil { t.Fatal(err) } clientSecretPath := filepath.Join(dir, "oidc_client_secret") if err := os.WriteFile(clientSecretPath, []byte("client-s3cret\n"), 0o600); err != nil { t.Fatal(err) } googleSecretPath := filepath.Join(dir, "google_client_secret") if err := os.WriteFile(googleSecretPath, []byte("google-s3cret\n"), 0o600); err != nil { t.Fatal(err) } vapidPair, vapidKeyPath := writeVAPIDKey(t, dir, "vapid_private_key") env := map[string]string{ EnvHTTPAddr: "127.0.0.1:9000", EnvPublicURL: "http://localhost:9000", EnvDataDir: dir, EnvShutdownTimeout: "1m30s", EnvSecretKeyFile: secretPath, EnvOIDCIssuer: "https://auth.example.com/application/o/mealquest/", EnvOIDCClientID: "mealquest", EnvOIDCClientSecretFile: clientSecretPath, EnvOIDCAllowedGroups: " mealquest-admin ,family", EnvGoogleClientID: "mealquest.apps.googleusercontent.com", EnvGoogleClientSecretFile: googleSecretPath, EnvVAPIDPublicKey: vapidPair.PublicKey, EnvVAPIDPrivateKeyFile: vapidKeyPath, EnvVAPIDSubject: "mailto:owner@example.com", EnvDefaultTZ: "Europe/London", EnvSearXNGURL: "http://searxng:8080", } cfg, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok }) if err != nil { t.Fatalf("load() error = %v", err) } if cfg.HTTPAddr != "127.0.0.1:9000" { t.Errorf("HTTPAddr = %q", cfg.HTTPAddr) } if cfg.PublicURL.String() != "http://localhost:9000" { t.Errorf("PublicURL = %q", cfg.PublicURL) } if cfg.DataDir != dir { t.Errorf("DataDir = %q, want %q", cfg.DataDir, dir) } if cfg.ShutdownTimeout != 90*time.Second { t.Errorf("ShutdownTimeout = %v, want 1m30s", cfg.ShutdownTimeout) } if cfg.SecretKey.Reveal() != "s3cret-value" { t.Errorf("SecretKey.Reveal() = %q, want trailing newline trimmed", cfg.SecretKey.Reveal()) } if got, want := cfg.OIDCIssuer.String(), "https://auth.example.com/application/o/mealquest/"; got != want { t.Errorf("OIDCIssuer = %q, want %q", got, want) } if cfg.OIDCClientID != "mealquest" { t.Errorf("OIDCClientID = %q", cfg.OIDCClientID) } if cfg.OIDCClientSecret.Reveal() != "client-s3cret" { t.Errorf("OIDCClientSecret.Reveal() = %q", cfg.OIDCClientSecret.Reveal()) } if cfg.GoogleClientID != "mealquest.apps.googleusercontent.com" { t.Errorf("GoogleClientID = %q", cfg.GoogleClientID) } if cfg.GoogleClientSecret.Reveal() != "google-s3cret" { t.Errorf("GoogleClientSecret.Reveal() = %q", cfg.GoogleClientSecret.Reveal()) } if want := (VAPID{PublicKey: vapidPair.PublicKey, PrivateKey: Secret{value: vapidPair.PrivateKey}, Subject: "mailto:owner@example.com"}); cfg.VAPID != want { t.Errorf("VAPID = %+v, want public key, trimmed private key and subject", cfg.VAPID) } if want := []string{"mealquest-admin", "family"}; !slices.Equal(cfg.OIDCAllowedGroups, want) { t.Errorf("OIDCAllowedGroups = %q, want %q", cfg.OIDCAllowedGroups, want) } if cfg.SessionIdle != 720*time.Hour || cfg.SessionMax != 4320*time.Hour { t.Errorf("SessionIdle, SessionMax = %v, %v, want defaults 720h, 4320h", cfg.SessionIdle, cfg.SessionMax) } if cfg.TrustedProxy != nil { t.Errorf("TrustedProxy = %v, want none when unset", cfg.TrustedProxy) } env[EnvSessionIdle] = "12h" env[EnvSessionMax] = "36h" env[EnvTrustedProxy] = "10.60.1.33, 172.16.0.0/12" cfg, err = load(func(k string) (string, bool) { v, ok := env[k]; return v, ok }) if err != nil { t.Fatalf("load() error = %v", err) } if cfg.SessionIdle != 12*time.Hour || cfg.SessionMax != 36*time.Hour { t.Errorf("SessionIdle, SessionMax = %v, %v, want 12h, 36h", cfg.SessionIdle, cfg.SessionMax) } want := forwarded.Trusted{netip.MustParsePrefix("10.60.1.33/32"), netip.MustParsePrefix("172.16.0.0/12")} if !slices.Equal(cfg.TrustedProxy, want) { t.Errorf("TrustedProxy = %v, want %v", cfg.TrustedProxy, want) } if cfg.DefaultTZ != "Europe/London" { t.Errorf("DefaultTZ = %q, want Europe/London", cfg.DefaultTZ) } if cfg.SearXNGURL != "http://searxng:8080" { t.Errorf("SearXNGURL = %q, want http://searxng:8080", cfg.SearXNGURL) } } func TestSecretRedacted(t *testing.T) { t.Parallel() s := Secret{value: "s3cret-value"} for _, format := range []string{"%v", "%s", "%+v", "%#v"} { cfg := Config{SecretKey: s, OIDCClientSecret: s, GoogleClientSecret: s, VAPID: VAPID{PrivateKey: s}} for _, out := range []string{fmt.Sprintf(format, s), fmt.Sprintf(format, cfg)} { if strings.Contains(out, "s3cret-value") { t.Errorf("format %s leaked secret: %s", format, out) } } } } // TestVAPIDErrorsNeverQuoteThePrivateKey checks every VAPID private key // problem names the variable without echoing the file's contents. func TestVAPIDErrorsNeverQuoteThePrivateKey(t *testing.T) { t.Parallel() dir := t.TempDir() pair, _ := writeVAPIDKey(t, dir, "unused") other, err := vapid.Generate() if err != nil { t.Fatal(err) } for name, contents := range map[string]string{ "mismatched": other.PrivateKey, "padded": pair.PrivateKey + "=", "truncated": pair.PrivateKey[:40], } { path := filepath.Join(dir, name) if err := os.WriteFile(path, []byte(contents+"\n"), 0o600); err != nil { t.Fatal(err) } env := map[string]string{EnvVAPIDPublicKey: pair.PublicKey, EnvVAPIDPrivateKeyFile: path, EnvVAPIDSubject: "mailto:owner@example.com"} _, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok }) var cerr *Error if !errors.As(err, &cerr) || !slices.ContainsFunc(cerr.Problems, func(p Problem) bool { return p.Var == EnvVAPIDPrivateKeyFile }) { t.Errorf("%s: error = %v, want problem for %s", name, err, EnvVAPIDPrivateKeyFile) } if err != nil && (strings.Contains(err.Error(), contents) || strings.Contains(err.Error(), contents[:16])) { t.Errorf("%s: error quotes the private key: %v", name, err) } } } func TestPublicURLWithCredentialsRejected(t *testing.T) { t.Parallel() for _, raw := range []string{ "https://alice:hunter2@example.com", "https://alice@example.com", "https://alice:hunter2@[bad", } { env := map[string]string{EnvPublicURL: raw} _, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok }) var cerr *Error if !errors.As(err, &cerr) || !slices.ContainsFunc(cerr.Problems, func(p Problem) bool { return p.Var == EnvPublicURL }) { t.Errorf("load(%q) error = %v, want problem for %s", raw, err, EnvPublicURL) } if err != nil && (strings.Contains(err.Error(), "hunter2") || strings.Contains(err.Error(), "alice")) { t.Errorf("load(%q) error leaks credentials: %v", raw, err) } } } func TestPublicOrigin(t *testing.T) { t.Parallel() u, err := url.Parse("https://alice:hunter2@meals.example.com:8443/app?token=x#frag") if err != nil { t.Fatal(err) } if got, want := (Config{PublicURL: u}).PublicOrigin(), "https://meals.example.com:8443"; got != want { t.Errorf("PublicOrigin() = %q, want %q", got, want) } }