CI / ci (push) Successful in 8m31s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
752 lines
24 KiB
Go
752 lines
24 KiB
Go
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 <owner@example.com>"}),
|
|
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)
|
|
}
|
|
}
|