Feature 01 task 1: Go module, fail-fast config and /health #2
+11
@@ -1,2 +1,13 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
design/.DS_Store
|
design/.DS_Store
|
||||||
|
|
||||||
|
# Secrets and local environment
|
||||||
|
.env*
|
||||||
|
deploy/secrets/
|
||||||
|
|
||||||
|
# Build artifacts and dependencies
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Local databases
|
||||||
|
*.db
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Command server runs the MealQuest HTTP service.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
// version is set at build time with -ldflags "-X main.version=<version>".
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
if err := run(cfg, logger); err != nil {
|
||||||
|
logger.Error("server stopped", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(cfg config.Config, logger *slog.Logger) error {
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: cfg.HTTPAddr,
|
||||||
|
Handler: server.NewRouter(logger, version),
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
|
||||||
|
}
|
||||||
|
|
||||||
|
errc := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
logListening(logger, cfg)
|
||||||
|
errc <- srv.ListenAndServe()
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-errc:
|
||||||
|
return err
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("shutting down", "timeout", cfg.ShutdownTimeout.String())
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
|
||||||
|
defer cancel()
|
||||||
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||||
|
return fmt.Errorf("shutdown: %w", err)
|
||||||
|
}
|
||||||
|
if err := <-errc; !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// logListening logs the startup line. Only the public URL's origin is logged
|
||||||
|
// so no credential-bearing URL component can reach the log.
|
||||||
|
func logListening(logger *slog.Logger, cfg config.Config) {
|
||||||
|
logger.Info("listening",
|
||||||
|
"addr", cfg.HTTPAddr,
|
||||||
|
"public_origin", cfg.PublicOrigin(),
|
||||||
|
"data_dir", cfg.DataDir,
|
||||||
|
"version", version,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLogListeningOmitsURLCredentials(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Load rejects such URLs; this proves the log stays safe even if one
|
||||||
|
// reached the Config some other way.
|
||||||
|
u, err := url.Parse("https://alice:hunter2@meals.example.com/app?token=t0ken")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logListening(slog.New(slog.NewJSONHandler(&buf, nil)), config.Config{HTTPAddr: ":8080", PublicURL: u})
|
||||||
|
|
||||||
|
out := buf.String()
|
||||||
|
for _, leak := range []string{"alice", "hunter2", "t0ken"} {
|
||||||
|
if strings.Contains(out, leak) {
|
||||||
|
t.Errorf("startup log leaks %q: %s", leak, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, `"public_origin":"https://meals.example.com"`) {
|
||||||
|
t.Errorf("startup log missing public origin: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest
|
||||||
|
|
||||||
|
go 1.25
|
||||||
|
|
||||||
|
require github.com/go-chi/chi/v5 v5.3.1
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||||
|
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
// Package config loads MealQuest configuration from MEALQUEST_* environment
|
||||||
|
// variables. Secrets are read only from files named by *_FILE variables.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Environment variable names.
|
||||||
|
const (
|
||||||
|
EnvHTTPAddr = "MEALQUEST_HTTP_ADDR"
|
||||||
|
EnvPublicURL = "MEALQUEST_PUBLIC_URL"
|
||||||
|
EnvDataDir = "MEALQUEST_DATA_DIR"
|
||||||
|
EnvShutdownTimeout = "MEALQUEST_SHUTDOWN_TIMEOUT"
|
||||||
|
EnvSecretKeyFile = "MEALQUEST_SECRET_KEY_FILE"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config is the validated service configuration.
|
||||||
|
type Config struct {
|
||||||
|
HTTPAddr string
|
||||||
|
PublicURL *url.URL
|
||||||
|
DataDir string
|
||||||
|
ShutdownTimeout time.Duration
|
||||||
|
SecretKey Secret
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
|
||||||
|
// that are safe to log.
|
||||||
|
func (c Config) PublicOrigin() string {
|
||||||
|
return c.PublicURL.Scheme + "://" + c.PublicURL.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads and validates configuration from the process environment.
|
||||||
|
// It reports every missing or invalid variable in a single *Error.
|
||||||
|
func Load() (Config, error) {
|
||||||
|
return load(os.LookupEnv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(lookup func(string) (string, bool)) (Config, error) {
|
||||||
|
r := reader{lookup: lookup}
|
||||||
|
cfg := Config{
|
||||||
|
HTTPAddr: r.listenAddr(EnvHTTPAddr),
|
||||||
|
PublicURL: r.httpURL(EnvPublicURL),
|
||||||
|
DataDir: r.dir(EnvDataDir),
|
||||||
|
ShutdownTimeout: r.duration(EnvShutdownTimeout),
|
||||||
|
SecretKey: r.secretFile(EnvSecretKeyFile),
|
||||||
|
}
|
||||||
|
if len(r.problems) > 0 {
|
||||||
|
return Config{}, &Error{Problems: r.problems}
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Problem describes one missing or invalid variable.
|
||||||
|
type Problem struct {
|
||||||
|
Var string
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error lists every configuration problem found by Load.
|
||||||
|
type Error struct {
|
||||||
|
Problems []Problem
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Error() string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("invalid configuration:")
|
||||||
|
for _, p := range e.Problems {
|
||||||
|
fmt.Fprintf(&b, "\n %s: %s", p.Var, p.Reason)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secret holds a sensitive value. It never prints its contents.
|
||||||
|
type Secret struct {
|
||||||
|
value string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reveal returns the secret value. Call it only where the value is used.
|
||||||
|
func (s Secret) Reveal() string { return s.value }
|
||||||
|
|
||||||
|
// String redacts the secret so it cannot leak through fmt or logs.
|
||||||
|
func (s Secret) String() string { return "[REDACTED]" }
|
||||||
|
|
||||||
|
// GoString redacts the secret for %#v.
|
||||||
|
func (s Secret) GoString() string { return "[REDACTED]" }
|
||||||
|
|
||||||
|
// reader reads variables and collects every problem instead of stopping at
|
||||||
|
// the first one.
|
||||||
|
type reader struct {
|
||||||
|
lookup func(string) (string, bool)
|
||||||
|
problems []Problem
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *reader) fail(name, format string, args ...any) {
|
||||||
|
r.problems = append(r.problems, Problem{Var: name, Reason: fmt.Sprintf(format, args...)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// required returns the variable's value, recording a problem when it is
|
||||||
|
// unset or empty.
|
||||||
|
func (r *reader) required(name string) (string, bool) {
|
||||||
|
v, ok := r.lookup(name)
|
||||||
|
if !ok || strings.TrimSpace(v) == "" {
|
||||||
|
r.fail(name, "required but not set")
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *reader) listenAddr(name string) string {
|
||||||
|
v, ok := r.required(name)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
host, port, err := net.SplitHostPort(v)
|
||||||
|
if err != nil {
|
||||||
|
r.fail(name, "invalid listen address %q (want host:port or :port)", v)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// An empty host listens on all interfaces.
|
||||||
|
if host != "" && net.ParseIP(host) == nil && !validHostname(host) {
|
||||||
|
r.fail(name, "invalid host %q in listen address (want IP address or hostname)", host)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if _, err := strconv.ParseUint(port, 10, 16); err != nil {
|
||||||
|
r.fail(name, "invalid port %q in listen address (want 0-65535)", port)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// validHostname reports whether h is a DNS hostname: dot-separated labels of
|
||||||
|
// 1-63 letters, digits and hyphens, not starting or ending with a hyphen.
|
||||||
|
func validHostname(h string) bool {
|
||||||
|
if len(h) > 253 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, label := range strings.Split(strings.TrimSuffix(h, "."), ".") {
|
||||||
|
if len(label) < 1 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range label {
|
||||||
|
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *reader) httpURL(name string) *url.URL {
|
||||||
|
v, ok := r.required(name)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// The value is never echoed: it may carry credentials.
|
||||||
|
u, err := url.Parse(v)
|
||||||
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" {
|
||||||
|
r.fail(name, "invalid URL (want absolute http:// or https:// URL)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if u.User != nil {
|
||||||
|
r.fail(name, "URL must not contain user credentials")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *reader) dir(name string) string {
|
||||||
|
v, ok := r.required(name)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
info, err := os.Stat(v)
|
||||||
|
if err != nil {
|
||||||
|
r.fail(name, "cannot access directory %q: %v", v, err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
r.fail(name, "%q is not a directory", v)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *reader) duration(name string) time.Duration {
|
||||||
|
v, ok := r.required(name)
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
d, err := time.ParseDuration(v)
|
||||||
|
if err != nil {
|
||||||
|
r.fail(name, "invalid duration %q (want e.g. 10s, 1m)", v)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if d <= 0 {
|
||||||
|
r.fail(name, "duration must be positive, got %q", v)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// secretFile reads a secret from the file named by name, which must end in
|
||||||
|
// _FILE. Setting the secret inline (name without _FILE) is rejected.
|
||||||
|
func (r *reader) secretFile(name string) Secret {
|
||||||
|
inline := strings.TrimSuffix(name, "_FILE")
|
||||||
|
if _, set := r.lookup(inline); set {
|
||||||
|
r.fail(inline, "secrets must not be set inline; use %s", name)
|
||||||
|
}
|
||||||
|
path, ok := r.required(name)
|
||||||
|
if !ok {
|
||||||
|
return Secret{}
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
// Report the cause without echoing any file contents.
|
||||||
|
r.fail(name, "cannot read secret file %q: %v", path, unwrapPathError(err))
|
||||||
|
return Secret{}
|
||||||
|
}
|
||||||
|
value := strings.TrimRight(string(data), "\r\n")
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
r.fail(name, "secret file %q is empty", path)
|
||||||
|
return Secret{}
|
||||||
|
}
|
||||||
|
return Secret{value: value}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unwrapPathError(err error) error {
|
||||||
|
var pe *os.PathError
|
||||||
|
if errors.As(err, &pe) {
|
||||||
|
return pe.Err
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
|
emptyPath := filepath.Join(dir, "empty_secret")
|
||||||
|
if err := os.WriteFile(emptyPath, []byte("\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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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: "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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: "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: "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 cfg != (Config{}) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
env := map[string]string{
|
||||||
|
EnvHTTPAddr: "127.0.0.1:9000",
|
||||||
|
EnvPublicURL: "http://localhost:9000",
|
||||||
|
EnvDataDir: dir,
|
||||||
|
EnvShutdownTimeout: "1m30s",
|
||||||
|
EnvSecretKeyFile: secretPath,
|
||||||
|
}
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecretRedacted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := Secret{value: "s3cret-value"}
|
||||||
|
for _, format := range []string{"%v", "%s", "%+v", "%#v"} {
|
||||||
|
cfg := Config{SecretKey: 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// Package server builds the MealQuest HTTP router.
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewRouter returns the HTTP handler for the service. version is reported by
|
||||||
|
// GET /health.
|
||||||
|
func NewRouter(logger *slog.Logger, version string) http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/health", health(logger, version))
|
||||||
|
r.MethodNotAllowed(apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed"))
|
||||||
|
r.NotFound(apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found"))
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
type errorBody struct {
|
||||||
|
Error apiError `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiError struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Retryable bool `json:"retryable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiErrorHandler responds with status and a fixed, non-retryable API error.
|
||||||
|
func apiErrorHandler(logger *slog.Logger, status int, code, message string) http.HandlerFunc {
|
||||||
|
body, err := json.Marshal(errorBody{Error: apiError{
|
||||||
|
Code: code,
|
||||||
|
Message: message,
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
panic(err) // fixed string and bool fields cannot fail to marshal
|
||||||
|
}
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
if _, err := w.Write(body); err != nil {
|
||||||
|
logger.WarnContext(r.Context(), "write error response", "code", code, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type healthResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func health(logger *slog.Logger, version string) http.HandlerFunc {
|
||||||
|
body, err := json.Marshal(healthResponse{Status: "ok", Version: version})
|
||||||
|
if err != nil {
|
||||||
|
panic(err) // two string fields cannot fail to marshal
|
||||||
|
}
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
if _, err := w.Write(body); err != nil {
|
||||||
|
logger.WarnContext(r.Context(), "write health response", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"maps"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHealth(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
srv := httptest.NewServer(NewRouter(logger, "1.2.3"))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
resp, err := http.Get(srv.URL + "/health")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if ct := resp.Header.Get("Content-Type"); ct != "application/json" {
|
||||||
|
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||||
|
}
|
||||||
|
var got map[string]string
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string]string{"status": "ok", "version": "1.2.3"}
|
||||||
|
if len(got) != len(want) || got["status"] != want["status"] || got["version"] != want["version"] {
|
||||||
|
t.Errorf("body = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthRejectsOtherMethods(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
NewRouter(logger, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/health", nil))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("status = %d, want 405", rec.Code)
|
||||||
|
}
|
||||||
|
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
||||||
|
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
Error map[string]any `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string]any{
|
||||||
|
"code": "method_not_allowed",
|
||||||
|
"message": "method not allowed",
|
||||||
|
"retryable": false,
|
||||||
|
}
|
||||||
|
if !maps.Equal(got.Error, want) {
|
||||||
|
t.Errorf("error body = %v, want %v", got.Error, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownPathNotFound(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
NewRouter(logger, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/unknown", nil))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("status = %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
||||||
|
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||||
|
}
|
||||||
|
const want = `{"error":{"code":"not_found","message":"not found","retryable":false}}`
|
||||||
|
if got := rec.Body.String(); got != want {
|
||||||
|
t.Errorf("body = %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user