Compare commits

...

4 Commits

Author SHA1 Message Date
f25bc437c4 updated hwsauth: uses new hws version 2026-01-23 12:55:44 +11:00
378bd8006d updated hws: expanded error page functionality 2026-01-23 12:33:13 +11:00
e9b96fedb1 Merge branch 'ezconf' 2026-01-21 19:23:27 +11:00
da6ad0cf2e updated ezconf 2026-01-21 19:23:12 +11:00
11 changed files with 789 additions and 74 deletions

View File

@@ -18,16 +18,16 @@ type EnvVar struct {
// ConfigLoader manages configuration loading from multiple sources // ConfigLoader manages configuration loading from multiple sources
type ConfigLoader struct { type ConfigLoader struct {
configFuncs map[string]ConfigFunc // Map of config names to ConfigFromEnv functions configFuncs map[string]ConfigFunc // Map of config names to ConfigFromEnv functions
packagePaths []string // Paths to packages to parse for ENV comments packagePaths []string // Paths to packages to parse for ENV comments
groupNames map[string]string // Map of package paths to group names groupNames map[string]string // Map of package paths to group names
extraEnvVars []EnvVar // Additional environment variables to track extraEnvVars []EnvVar // Additional environment variables to track
envVars []EnvVar // All extracted environment variables envVars []EnvVar // All extracted environment variables
configs map[string]interface{} // Loaded configurations configs map[string]any // Loaded configurations
} }
// ConfigFunc is a function that loads configuration from environment variables // ConfigFunc is a function that loads configuration from environment variables
type ConfigFunc func() (interface{}, error) type ConfigFunc func() (any, error)
// New creates a new ConfigLoader // New creates a new ConfigLoader
func New() *ConfigLoader { func New() *ConfigLoader {
@@ -37,7 +37,7 @@ func New() *ConfigLoader {
groupNames: make(map[string]string), groupNames: make(map[string]string),
extraEnvVars: make([]EnvVar, 0), extraEnvVars: make([]EnvVar, 0),
envVars: make([]EnvVar, 0), envVars: make([]EnvVar, 0),
configs: make(map[string]interface{}), configs: make(map[string]any),
} }
} }
@@ -72,8 +72,12 @@ func (cl *ConfigLoader) AddEnvVar(envVar EnvVar) {
cl.extraEnvVars = append(cl.extraEnvVars, envVar) cl.extraEnvVars = append(cl.extraEnvVars, envVar)
} }
// Load loads all configurations and extracts environment variables // ParseEnvVars extracts environment variables from packages and extra vars
func (cl *ConfigLoader) Load() error { // This can be called without having actual environment variables set
func (cl *ConfigLoader) ParseEnvVars() error {
// Clear existing env vars to prevent duplicates
cl.envVars = make([]EnvVar, 0)
// Parse packages for ENV comments // Parse packages for ENV comments
for _, pkgPath := range cl.packagePaths { for _, pkgPath := range cl.packagePaths {
envVars, err := ParseConfigPackage(pkgPath) envVars, err := ParseConfigPackage(pkgPath)
@@ -102,6 +106,12 @@ func (cl *ConfigLoader) Load() error {
cl.envVars[i].CurrentValue = os.Getenv(cl.envVars[i].Name) cl.envVars[i].CurrentValue = os.Getenv(cl.envVars[i].Name)
} }
return nil
}
// LoadConfigs executes the config functions to load actual configurations
// This should be called after environment variables are properly set
func (cl *ConfigLoader) LoadConfigs() error {
// Load configurations // Load configurations
for name, fn := range cl.configFuncs { for name, fn := range cl.configFuncs {
cfg, err := fn() cfg, err := fn()
@@ -114,14 +124,22 @@ func (cl *ConfigLoader) Load() error {
return nil return nil
} }
// Load loads all configurations and extracts environment variables
func (cl *ConfigLoader) Load() error {
if err := cl.ParseEnvVars(); err != nil {
return err
}
return cl.LoadConfigs()
}
// GetConfig returns a loaded configuration by name // GetConfig returns a loaded configuration by name
func (cl *ConfigLoader) GetConfig(name string) (interface{}, bool) { func (cl *ConfigLoader) GetConfig(name string) (any, bool) {
cfg, ok := cl.configs[name] cfg, ok := cl.configs[name]
return cfg, ok return cfg, ok
} }
// GetAllConfigs returns all loaded configurations // GetAllConfigs returns all loaded configurations
func (cl *ConfigLoader) GetAllConfigs() map[string]interface{} { func (cl *ConfigLoader) GetAllConfigs() map[string]any {
return cl.configs return cl.configs
} }

View File

@@ -3,6 +3,7 @@ package ezconf
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
@@ -240,6 +241,163 @@ func TestGetEnvVars(t *testing.T) {
} }
} }
func TestParseEnvVars(t *testing.T) {
loader := New()
// Add a test config function
loader.AddConfigFunc("test", func() (interface{}, error) {
return "test config", nil
})
// Add current package path
loader.AddPackagePath(".")
// Add an extra env var
loader.AddEnvVar(EnvVar{
Name: "EXTRA_VAR",
Description: "Extra test variable",
Default: "extra",
})
err := loader.ParseEnvVars()
if err != nil {
t.Fatalf("ParseEnvVars failed: %v", err)
}
// Check that env vars were extracted
envVars := loader.GetEnvVars()
if len(envVars) == 0 {
t.Error("expected at least one env var")
}
// Check for extra var
foundExtra := false
for _, ev := range envVars {
if ev.Name == "EXTRA_VAR" {
foundExtra = true
break
}
}
if !foundExtra {
t.Error("extra env var not found")
}
// Check that configs are NOT loaded (should be empty)
configs := loader.GetAllConfigs()
if len(configs) != 0 {
t.Errorf("expected no configs loaded after ParseEnvVars, got %d", len(configs))
}
}
func TestLoadConfigs(t *testing.T) {
loader := New()
// Add a test config function
testCfg := struct {
Value string
}{Value: "test"}
loader.AddConfigFunc("test", func() (interface{}, error) {
return testCfg, nil
})
// Manually set some env vars (simulating ParseEnvVars already called)
loader.envVars = []EnvVar{
{Name: "TEST_VAR", Description: "Test variable"},
}
err := loader.LoadConfigs()
if err != nil {
t.Fatalf("LoadConfigs failed: %v", err)
}
// Check that config was loaded
cfg, ok := loader.GetConfig("test")
if !ok {
t.Error("test config not loaded")
}
if cfg == nil {
t.Error("test config is nil")
}
_ = cfg // Use the variable to avoid unused variable error
// Check that env vars are NOT modified (should remain as set)
envVars := loader.GetEnvVars()
if len(envVars) != 1 {
t.Errorf("expected 1 env var, got %d", len(envVars))
}
}
func TestLoadConfigs_Error(t *testing.T) {
loader := New()
loader.AddConfigFunc("error", func() (interface{}, error) {
return nil, os.ErrNotExist
})
err := loader.LoadConfigs()
if err == nil {
t.Error("expected error from failing config func")
}
}
func TestParseEnvVars_Then_LoadConfigs(t *testing.T) {
loader := New()
// Add a test config function
testCfg := struct {
Value string
}{Value: "test"}
loader.AddConfigFunc("test", func() (interface{}, error) {
return testCfg, nil
})
// Add current package path
loader.AddPackagePath(".")
// Add an extra env var
loader.AddEnvVar(EnvVar{
Name: "EXTRA_VAR",
Description: "Extra test variable",
Default: "extra",
})
// First parse env vars
err := loader.ParseEnvVars()
if err != nil {
t.Fatalf("ParseEnvVars failed: %v", err)
}
// Check env vars are extracted but configs are not loaded
envVars := loader.GetEnvVars()
if len(envVars) == 0 {
t.Error("expected env vars to be extracted")
}
configs := loader.GetAllConfigs()
if len(configs) != 0 {
t.Error("expected no configs loaded yet")
}
// Then load configs
err = loader.LoadConfigs()
if err != nil {
t.Fatalf("LoadConfigs failed: %v", err)
}
// Check both env vars and configs are loaded
_, ok := loader.GetConfig("test")
if !ok {
t.Error("test config not loaded after LoadConfigs")
}
configs = loader.GetAllConfigs()
if len(configs) != 1 {
t.Errorf("expected 1 config loaded, got %d", len(configs))
}
}
func TestLoad_Integration(t *testing.T) { func TestLoad_Integration(t *testing.T) {
// Integration test with real hlog package // Integration test with real hlog package
hlogPath := filepath.Join("..", "hlog") hlogPath := filepath.Join("..", "hlog")
@@ -269,3 +427,62 @@ func TestLoad_Integration(t *testing.T) {
t.Logf(" %s: %s (default: %s, required: %t)", ev.Name, ev.Description, ev.Default, ev.Required) t.Logf(" %s: %s (default: %s, required: %t)", ev.Name, ev.Description, ev.Default, ev.Required)
} }
} }
func TestParseEnvVars_GenerateEnvFile_Integration(t *testing.T) {
// Test the new separated ParseEnvVars functionality
hlogPath := filepath.Join("..", "hlog")
if _, err := os.Stat(hlogPath); os.IsNotExist(err) {
t.Skip("hlog package not found, skipping integration test")
}
loader := New()
// Add hlog package
if err := loader.AddPackagePath(hlogPath); err != nil {
t.Fatalf("failed to add hlog package: %v", err)
}
// Parse env vars without loading configs (this should work even if required env vars are missing)
if err := loader.ParseEnvVars(); err != nil {
t.Fatalf("ParseEnvVars failed: %v", err)
}
envVars := loader.GetEnvVars()
if len(envVars) == 0 {
t.Error("expected env vars from hlog package")
}
// Now test that we can generate an env file without calling Load()
tempDir := t.TempDir()
envFile := filepath.Join(tempDir, "test-generated.env")
err := loader.GenerateEnvFile(envFile, false)
if err != nil {
t.Fatalf("GenerateEnvFile failed: %v", err)
}
// Verify the file was created and contains expected content
content, err := os.ReadFile(envFile)
if err != nil {
t.Fatalf("failed to read generated file: %v", err)
}
output := string(content)
if !strings.Contains(output, "# Environment Configuration") {
t.Error("expected header in generated file")
}
// Should contain environment variables from hlog
foundHlogVar := false
for _, ev := range envVars {
if strings.Contains(output, ev.Name) {
foundHlogVar = true
break
}
}
if !foundHlogVar {
t.Error("expected to find at least one hlog environment variable in generated file")
}
t.Logf("Successfully generated env file with %d variables", len(envVars))
}

View File

@@ -12,7 +12,7 @@ import (
// PrintEnvVars prints all environment variables to the provided writer // PrintEnvVars prints all environment variables to the provided writer
func (cl *ConfigLoader) PrintEnvVars(w io.Writer, showValues bool) error { func (cl *ConfigLoader) PrintEnvVars(w io.Writer, showValues bool) error {
if cl.envVars == nil || len(cl.envVars) == 0 { if len(cl.envVars) == 0 {
return errors.New("no environment variables loaded (did you call Load()?)") return errors.New("no environment variables loaded (did you call Load()?)")
} }

View File

@@ -360,3 +360,46 @@ func TestPrintEnvVarsStdout_NoEnvVars(t *testing.T) {
t.Error("expected error when no env vars are loaded") t.Error("expected error when no env vars are loaded")
} }
} }
func TestPrintEnvVars_AfterParseEnvVars(t *testing.T) {
loader := New()
// Add some env vars manually to simulate ParseEnvVars
loader.envVars = []EnvVar{
{
Name: "LOG_LEVEL",
Description: "Log level for the application",
Required: false,
Default: "info",
CurrentValue: "",
},
{
Name: "DATABASE_URL",
Description: "Database connection string",
Required: true,
Default: "",
CurrentValue: "",
},
}
// Test that PrintEnvVars works after ParseEnvVars (without Load)
buf := &bytes.Buffer{}
err := loader.PrintEnvVars(buf, false)
if err != nil {
t.Fatalf("PrintEnvVars failed: %v", err)
}
output := buf.String()
if !strings.Contains(output, "LOG_LEVEL") {
t.Error("output should contain LOG_LEVEL")
}
if !strings.Contains(output, "DATABASE_URL") {
t.Error("output should contain DATABASE_URL")
}
if !strings.Contains(output, "(required)") {
t.Error("output should indicate required variables")
}
if !strings.Contains(output, "(default: info)") {
t.Error("output should contain default value")
}
}

View File

@@ -31,7 +31,7 @@ const (
// ErrorPageFunc is a function that returns an ErrorPage with the specified HTTP Status code // ErrorPageFunc is a function that returns an ErrorPage with the specified HTTP Status code
// This will be called by the server when it needs to render an error page // This will be called by the server when it needs to render an error page
type ErrorPageFunc func(errorCode int) (ErrorPage, error) type ErrorPageFunc func(error HWSError) (ErrorPage, error)
// ErrorPage must implement a Render() function that takes in a context and ResponseWriter, // ErrorPage must implement a Render() function that takes in a context and ResponseWriter,
// and should write a reponse as output to the ResponseWriter. // and should write a reponse as output to the ResponseWriter.
@@ -40,11 +40,11 @@ type ErrorPage interface {
Render(ctx context.Context, w io.Writer) error Render(ctx context.Context, w io.Writer) error
} }
// TODO: add test for ErrorPageFunc that returns an error // AddErrorPage registers a handler that returns an ErrorPage
func (server *Server) AddErrorPage(pageFunc ErrorPageFunc) error { func (server *Server) AddErrorPage(pageFunc ErrorPageFunc) error {
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/", nil) req := httptest.NewRequest("GET", "/", nil)
page, err := pageFunc(http.StatusInternalServerError) page, err := pageFunc(HWSError{StatusCode: http.StatusInternalServerError})
if err != nil { if err != nil {
return errors.Wrap(err, "An error occured when trying to get the error page") return errors.Wrap(err, "An error occured when trying to get the error page")
} }
@@ -88,7 +88,7 @@ func (server *Server) ThrowError(w http.ResponseWriter, r *http.Request, error H
} }
if error.RenderErrorPage { if error.RenderErrorPage {
server.LogError(HWSError{Message: "Error page rendering", Error: nil, Level: ErrorDEBUG}) server.LogError(HWSError{Message: "Error page rendering", Error: nil, Level: ErrorDEBUG})
errPage, err := server.errorPage(error.StatusCode) errPage, err := server.errorPage(error)
if err != nil { if err != nil {
server.LogError(HWSError{Message: "Failed to get a valid error page", Error: err}) server.LogError(HWSError{Message: "Failed to get a valid error page", Error: err})
} }

View File

@@ -17,13 +17,13 @@ import (
type goodPage struct{} type goodPage struct{}
type badPage struct{} type badPage struct{}
func goodRender(code int) (hws.ErrorPage, error) { func goodRender(error hws.HWSError) (hws.ErrorPage, error) {
return goodPage{}, nil return goodPage{}, nil
} }
func badRender1(code int) (hws.ErrorPage, error) { func badRender1(error hws.HWSError) (hws.ErrorPage, error) {
return badPage{}, nil return badPage{}, nil
} }
func badRender2(code int) (hws.ErrorPage, error) { func badRender2(error hws.HWSError) (hws.ErrorPage, error) {
return nil, errors.New("I'm an error") return nil, errors.New("I'm an error")
} }

View File

@@ -149,7 +149,8 @@ func Test_Start_Errors(t *testing.T) {
}) })
require.NoError(t, err) require.NoError(t, err)
err = server.Start(t.Context()) var nilCtx context.Context = nil
err = server.Start(nilCtx)
assert.Error(t, err) assert.Error(t, err)
assert.Contains(t, err.Error(), "Context cannot be nil") assert.Contains(t, err.Error(), "Context cannot be nil")
}) })
@@ -163,7 +164,8 @@ func Test_Shutdown_Errors(t *testing.T) {
startTestServer(t, server) startTestServer(t, server)
<-server.Ready() <-server.Ready()
err := server.Shutdown(t.Context()) var nilCtx context.Context = nil
err := server.Shutdown(nilCtx)
assert.Error(t, err) assert.Error(t, err)
assert.Contains(t, err.Error(), "Context cannot be nil") assert.Contains(t, err.Error(), "Context cannot be nil")

View File

@@ -5,20 +5,24 @@ go 1.25.5
require ( require (
git.haelnorr.com/h/golib/cookies v0.9.0 git.haelnorr.com/h/golib/cookies v0.9.0
git.haelnorr.com/h/golib/env v0.9.1 git.haelnorr.com/h/golib/env v0.9.1
git.haelnorr.com/h/golib/hws v0.2.0 git.haelnorr.com/h/golib/hlog v0.10.4
git.haelnorr.com/h/golib/jwt v0.10.0 git.haelnorr.com/h/golib/hws v0.3.0
git.haelnorr.com/h/golib/jwt v0.10.1
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
git.haelnorr.com/h/golib/hlog v0.9.1 github.com/stretchr/testify v1.11.1
) )
require ( require (
github.com/rs/zerolog v1.34.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rs/zerolog v1.34.0 // indirect
golang.org/x/sys v0.40.0 // indirect golang.org/x/sys v0.40.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/apimachinery v0.35.0 // indirect k8s.io/apimachinery v0.35.0 // indirect
k8s.io/klog/v2 v2.130.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/utils v0.0.0-20260108192941-914a6e750570 // indirect k8s.io/utils v0.0.0-20260108192941-914a6e750570 // indirect

View File

@@ -4,10 +4,16 @@ git.haelnorr.com/h/golib/env v0.9.1 h1:2Vsj+mJKnO5f1Md1GO5v9ggLN5zWa0baCewcSHTjo
git.haelnorr.com/h/golib/env v0.9.1/go.mod h1:glUQVdA1HMKX1avTDyTyuhcr36SSxZtlJxKDT5KTztg= git.haelnorr.com/h/golib/env v0.9.1/go.mod h1:glUQVdA1HMKX1avTDyTyuhcr36SSxZtlJxKDT5KTztg=
git.haelnorr.com/h/golib/hlog v0.9.1 h1:9VmE/IQTfD8LAEyTbUCZLy/+8PbcHA1Kob/WQHRHKzc= git.haelnorr.com/h/golib/hlog v0.9.1 h1:9VmE/IQTfD8LAEyTbUCZLy/+8PbcHA1Kob/WQHRHKzc=
git.haelnorr.com/h/golib/hlog v0.9.1/go.mod h1:oOlzb8UVHUYP1k7dN5PSJXVskAB2z8EYgRN85jAi0Zk= git.haelnorr.com/h/golib/hlog v0.9.1/go.mod h1:oOlzb8UVHUYP1k7dN5PSJXVskAB2z8EYgRN85jAi0Zk=
git.haelnorr.com/h/golib/hlog v0.10.4 h1:vpCsV/OddjIYx8F48U66WxojjmhEbeLGQAOBG4ViSRQ=
git.haelnorr.com/h/golib/hlog v0.10.4/go.mod h1:+wJ8vecQY/JITTXKmI3JfkHiUGyMs7N6wooj2wuWZbc=
git.haelnorr.com/h/golib/hws v0.2.0 h1:MR2Tu2qPaW+/oK8aXFJLRFaYZIHgKiex3t3zE41cu1U= git.haelnorr.com/h/golib/hws v0.2.0 h1:MR2Tu2qPaW+/oK8aXFJLRFaYZIHgKiex3t3zE41cu1U=
git.haelnorr.com/h/golib/hws v0.2.0/go.mod h1:6ZlRKnt8YMpv5XcMXmyBGmD1/euvBo3d1azEvHJjOLo= git.haelnorr.com/h/golib/hws v0.2.0/go.mod h1:6ZlRKnt8YMpv5XcMXmyBGmD1/euvBo3d1azEvHJjOLo=
git.haelnorr.com/h/golib/hws v0.3.0 h1:/YGzxd3sRR3DFU6qVZxpJMKV3W2wCONqZKYUDIercCo=
git.haelnorr.com/h/golib/hws v0.3.0/go.mod h1:6ZlRKnt8YMpv5XcMXmyBGmD1/euvBo3d1azEvHJjOLo=
git.haelnorr.com/h/golib/jwt v0.10.0 h1:8cI8mSnb8X+EmJtrBO/5UZwuBMtib0IE9dv85gkm94E= git.haelnorr.com/h/golib/jwt v0.10.0 h1:8cI8mSnb8X+EmJtrBO/5UZwuBMtib0IE9dv85gkm94E=
git.haelnorr.com/h/golib/jwt v0.10.0/go.mod h1:fbuPrfucT9lL0faV5+Q5Gk9WFJxPlwzRPpbMQKYZok4= git.haelnorr.com/h/golib/jwt v0.10.0/go.mod h1:fbuPrfucT9lL0faV5+Q5Gk9WFJxPlwzRPpbMQKYZok4=
git.haelnorr.com/h/golib/jwt v0.10.1 h1:1Adxt9H3Y4fWFvFjWpvg/vSFhbgCMDMxgiE3m7KvDMI=
git.haelnorr.com/h/golib/jwt v0.10.1/go.mod h1:fbuPrfucT9lL0faV5+Q5Gk9WFJxPlwzRPpbMQKYZok4=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
@@ -41,6 +47,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8=

440
hwsauth/hwsauth_test.go Normal file
View File

@@ -0,0 +1,440 @@
package hwsauth
import (
"context"
"database/sql"
"io"
"net/http/httptest"
"os"
"testing"
"git.haelnorr.com/h/golib/hlog"
"git.haelnorr.com/h/golib/hws"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type TestModel struct {
ID int
}
func (tm TestModel) GetID() int {
return tm.ID
}
type TestTransaction struct {
}
func (tt *TestTransaction) Exec(query string, args ...any) (sql.Result, error) {
return nil, nil
}
func (tt *TestTransaction) Query(query string, args ...any) (*sql.Rows, error) {
return nil, nil
}
func (tt *TestTransaction) Commit() error {
return nil
}
func (tt *TestTransaction) Rollback() error {
return nil
}
type TestErrorPage struct{}
func (tep TestErrorPage) Render(ctx context.Context, w io.Writer) error {
return nil
}
func TestGetNil(t *testing.T) {
var zero TestModel
result := getNil[TestModel]()
assert.Equal(t, zero, result)
}
func TestSetAndGetAuthenticatedModel(t *testing.T) {
ctx := context.Background()
model := TestModel{ID: 123}
authModel := authenticatedModel[TestModel]{
model: model,
fresh: 1234567890,
}
newCtx := setAuthenticatedModel(ctx, authModel)
retrieved, ok := getAuthorizedModel[TestModel](newCtx)
assert.True(t, ok)
assert.Equal(t, model, retrieved.model)
assert.Equal(t, int64(1234567890), retrieved.fresh)
}
func TestGetAuthorizedModel_NotSet(t *testing.T) {
ctx := context.Background()
retrieved, ok := getAuthorizedModel[TestModel](ctx)
assert.False(t, ok)
var zero TestModel
assert.Equal(t, zero, retrieved.model)
assert.Equal(t, int64(0), retrieved.fresh)
}
func TestCurrentModel(t *testing.T) {
auth := &Authenticator[TestModel, DBTransaction]{}
t.Run("nil context", func(t *testing.T) {
var nilContext context.Context = nil
result := auth.CurrentModel(nilContext)
var zero TestModel
assert.Equal(t, zero, result)
})
t.Run("context without authenticated model", func(t *testing.T) {
ctx := context.Background()
result := auth.CurrentModel(ctx)
var zero TestModel
assert.Equal(t, zero, result)
})
t.Run("context with authenticated model", func(t *testing.T) {
ctx := context.Background()
model := TestModel{ID: 456}
authModel := authenticatedModel[TestModel]{
model: model,
fresh: 1234567890,
}
ctx = setAuthenticatedModel(ctx, authModel)
result := auth.CurrentModel(ctx)
assert.Equal(t, model, result)
assert.Equal(t, 456, result.GetID())
})
}
func TestConfigFromEnv_MissingSecretKey(t *testing.T) {
// Clear environment variables
originalSecret := os.Getenv("HWSAUTH_SECRET_KEY")
os.Setenv("HWSAUTH_SECRET_KEY", "")
defer os.Setenv("HWSAUTH_SECRET_KEY", originalSecret)
_, err := ConfigFromEnv()
assert.Error(t, err)
assert.Contains(t, err.Error(), "Envar not set: HWSAUTH_SECRET_KEY")
}
func TestConfigFromEnv_SSLWithoutTrustedHost(t *testing.T) {
// Clear environment variables
t.Setenv("HWSAUTH_SECRET_KEY", "test-secret")
t.Setenv("HWSAUTH_SSL", "true")
t.Setenv("HWSAUTH_TRUSTED_HOST", "")
defer func() {
t.Setenv("HWSAUTH_SECRET_KEY", "")
t.Setenv("HWSAUTH_SSL", "")
t.Setenv("HWSAUTH_TRUSTED_HOST", "")
}()
_, err := ConfigFromEnv()
assert.Error(t, err)
assert.Contains(t, err.Error(), "SSL is enabled and no HWS_TRUSTED_HOST set")
}
func TestConfigFromEnv_ValidMinimalConfig(t *testing.T) {
// Set environment variables
t.Setenv("HWSAUTH_SECRET_KEY", "test-secret-key")
defer t.Setenv("HWSAUTH_SECRET_KEY", "")
cfg, err := ConfigFromEnv()
assert.NoError(t, err)
assert.Equal(t, "test-secret-key", cfg.SecretKey)
assert.Equal(t, false, cfg.SSL)
assert.Equal(t, int64(5), cfg.AccessTokenExpiry)
assert.Equal(t, int64(1440), cfg.RefreshTokenExpiry)
assert.Equal(t, int64(5), cfg.TokenFreshTime)
assert.Equal(t, "/profile", cfg.LandingPage)
assert.Equal(t, "postgres", cfg.DatabaseType)
assert.Equal(t, "15", cfg.DatabaseVersion)
assert.Equal(t, "jwtblacklist", cfg.JWTTableName)
}
func TestConfigFromEnv_ValidFullConfig(t *testing.T) {
// Set environment variables
t.Setenv("HWSAUTH_SECRET_KEY", "custom-secret")
t.Setenv("HWSAUTH_SSL", "true")
t.Setenv("HWSAUTH_TRUSTED_HOST", "example.com")
t.Setenv("HWSAUTH_ACCESS_TOKEN_EXPIRY", "15")
t.Setenv("HWSAUTH_REFRESH_TOKEN_EXPIRY", "2880")
t.Setenv("HWSAUTH_TOKEN_FRESH_TIME", "10")
t.Setenv("HWSAUTH_LANDING_PAGE", "/dashboard")
t.Setenv("HWSAUTH_DATABASE_TYPE", "mysql")
t.Setenv("HWSAUTH_DATABASE_VERSION", "8.0")
t.Setenv("HWSAUTH_JWT_TABLE_NAME", "custom_tokens")
defer func() {
t.Setenv("HWSAUTH_SECRET_KEY", "")
t.Setenv("HWSAUTH_SSL", "")
t.Setenv("HWSAUTH_TRUSTED_HOST", "")
t.Setenv("HWSAUTH_ACCESS_TOKEN_EXPIRY", "")
t.Setenv("HWSAUTH_REFRESH_TOKEN_EXPIRY", "")
t.Setenv("HWSAUTH_TOKEN_FRESH_TIME", "")
t.Setenv("HWSAUTH_LANDING_PAGE", "")
t.Setenv("HWSAUTH_DATABASE_TYPE", "")
t.Setenv("HWSAUTH_DATABASE_VERSION", "")
t.Setenv("HWSAUTH_JWT_TABLE_NAME", "")
}()
cfg, err := ConfigFromEnv()
assert.NoError(t, err)
assert.Equal(t, "custom-secret", cfg.SecretKey)
assert.Equal(t, true, cfg.SSL)
assert.Equal(t, "example.com", cfg.TrustedHost)
assert.Equal(t, int64(15), cfg.AccessTokenExpiry)
assert.Equal(t, int64(2880), cfg.RefreshTokenExpiry)
assert.Equal(t, int64(10), cfg.TokenFreshTime)
assert.Equal(t, "/dashboard", cfg.LandingPage)
assert.Equal(t, "mysql", cfg.DatabaseType)
assert.Equal(t, "8.0", cfg.DatabaseVersion)
assert.Equal(t, "custom_tokens", cfg.JWTTableName)
}
func TestNewAuthenticator_NilConfig(t *testing.T) {
load := func(ctx context.Context, tx DBTransaction, id int) (TestModel, error) {
return TestModel{ID: id}, nil
}
server := &hws.Server{}
beginTx := func(ctx context.Context) (DBTransaction, error) {
return &TestTransaction{}, nil
}
logger := &hlog.Logger{}
errorPage := func(error hws.HWSError) (hws.ErrorPage, error) {
return TestErrorPage{}, nil
}
auth, err := NewAuthenticator(
nil,
load,
server,
beginTx,
logger,
errorPage,
)
assert.Error(t, err)
assert.Nil(t, auth)
assert.Contains(t, err.Error(), "Config is required")
}
func TestNewAuthenticator_MissingSecretKey(t *testing.T) {
cfg := &Config{
SecretKey: "",
}
load := func(ctx context.Context, tx DBTransaction, id int) (TestModel, error) {
return TestModel{ID: id}, nil
}
server := &hws.Server{}
beginTx := func(ctx context.Context) (DBTransaction, error) {
return &TestTransaction{}, nil
}
logger := &hlog.Logger{}
errorPage := func(error hws.HWSError) (hws.ErrorPage, error) {
return TestErrorPage{}, nil
}
auth, err := NewAuthenticator(
cfg,
load,
server,
beginTx,
logger,
errorPage,
)
assert.Error(t, err)
assert.Nil(t, auth)
assert.Contains(t, err.Error(), "SecretKey is required")
}
func TestNewAuthenticator_NilLoadFunction(t *testing.T) {
cfg := &Config{
SecretKey: "test-secret",
}
server := &hws.Server{}
beginTx := func(ctx context.Context) (DBTransaction, error) {
return &TestTransaction{}, nil
}
logger := &hlog.Logger{}
errorPage := func(error hws.HWSError) (hws.ErrorPage, error) {
return TestErrorPage{}, nil
}
auth, err := NewAuthenticator[TestModel, DBTransaction](
cfg,
nil,
server,
beginTx,
logger,
errorPage,
)
assert.Error(t, err)
assert.Nil(t, auth)
assert.Contains(t, err.Error(), "No function to load model supplied")
}
func TestNewAuthenticator_SSLWithoutTrustedHost(t *testing.T) {
cfg := &Config{
SecretKey: "test-secret",
SSL: true,
}
load := func(ctx context.Context, tx DBTransaction, id int) (TestModel, error) {
return TestModel{ID: id}, nil
}
server := &hws.Server{}
beginTx := func(ctx context.Context) (DBTransaction, error) {
return &TestTransaction{}, nil
}
logger := &hlog.Logger{}
errorPage := func(error hws.HWSError) (hws.ErrorPage, error) {
return TestErrorPage{}, nil
}
auth, err := NewAuthenticator(
cfg,
load,
server,
beginTx,
logger,
errorPage,
)
assert.Error(t, err)
assert.Nil(t, auth)
assert.Contains(t, err.Error(), "TrustedHost is required when SSL is enabled")
}
func TestNewAuthenticator_ValidMinimalConfig(t *testing.T) {
cfg := &Config{
SecretKey: "test-secret",
TrustedHost: "example.com",
}
load := func(ctx context.Context, tx DBTransaction, id int) (TestModel, error) {
return TestModel{ID: id}, nil
}
server := &hws.Server{}
beginTx := func(ctx context.Context) (DBTransaction, error) {
return &TestTransaction{}, nil
}
logger := &hlog.Logger{}
errorPage := func(error hws.HWSError) (hws.ErrorPage, error) {
return TestErrorPage{}, nil
}
auth, err := NewAuthenticator(
cfg,
load,
server,
beginTx,
logger,
errorPage,
)
require.NoError(t, err)
require.NotNil(t, auth)
assert.Equal(t, false, auth.SSL)
assert.Equal(t, "/profile", auth.LandingPage)
}
func TestModelInterface(t *testing.T) {
t.Run("TestModel implements Model interface", func(t *testing.T) {
var _ Model = TestModel{}
})
t.Run("GetID method", func(t *testing.T) {
model := TestModel{ID: 789}
assert.Equal(t, 789, model.GetID())
})
}
func TestGetAuthenticatedUser_NoTokens(t *testing.T) {
cfg := &Config{
SecretKey: "test-secret",
TrustedHost: "example.com",
}
load := func(ctx context.Context, tx DBTransaction, id int) (TestModel, error) {
return TestModel{ID: id}, nil
}
server := &hws.Server{}
beginTx := func(ctx context.Context) (DBTransaction, error) {
return &TestTransaction{}, nil
}
logger := &hlog.Logger{}
errorPage := func(error hws.HWSError) (hws.ErrorPage, error) {
return TestErrorPage{}, nil
}
auth, err := NewAuthenticator(
cfg,
load,
server,
beginTx,
logger,
errorPage,
)
require.NoError(t, err)
tx := &TestTransaction{}
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/", nil)
model, err := auth.getAuthenticatedUser(tx, w, r)
assert.Error(t, err)
assert.Contains(t, err.Error(), "No token strings provided")
var zero TestModel
assert.Equal(t, zero, model.model)
}
func TestLogin_BasicFunctionality(t *testing.T) {
cfg := &Config{
SecretKey: "test-secret",
TrustedHost: "example.com",
}
load := func(ctx context.Context, tx DBTransaction, id int) (TestModel, error) {
return TestModel{ID: id}, nil
}
server := &hws.Server{}
beginTx := func(ctx context.Context) (DBTransaction, error) {
return &TestTransaction{}, nil
}
logger := &hlog.Logger{}
errorPage := func(error hws.HWSError) (hws.ErrorPage, error) {
return TestErrorPage{}, nil
}
auth, err := NewAuthenticator(
cfg,
load,
server,
beginTx,
logger,
errorPage,
)
require.NoError(t, err)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/", nil)
user := TestModel{ID: 123}
rememberMe := true
// This test mainly checks that the function doesn't panic and has right call signature
// The actual JWT functionality is tested in jwt package itself
assert.NotPanics(t, func() {
auth.Login(w, r, user, rememberMe)
})
}

View File

@@ -5,6 +5,7 @@ import (
"time" "time"
"git.haelnorr.com/h/golib/hws" "git.haelnorr.com/h/golib/hws"
"github.com/pkg/errors"
) )
// LoginReq returns a middleware that requires the user to be authenticated. // LoginReq returns a middleware that requires the user to be authenticated.
@@ -18,23 +19,14 @@ func (auth *Authenticator[T, TX]) LoginReq(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, ok := getAuthorizedModel[T](r.Context()) _, ok := getAuthorizedModel[T](r.Context())
if !ok { if !ok {
page, err := auth.errorPage(http.StatusUnauthorized) err := auth.server.ThrowError(w, r, hws.HWSError{
Error: errors.New("Login required"),
Message: "Please login to view this page",
StatusCode: http.StatusUnauthorized,
RenderErrorPage: true,
})
if err != nil { if err != nil {
auth.server.ThrowError(w, r, hws.HWSError{ auth.server.ThrowFatal(w, err)
Error: err,
Message: "Failed to get valid error page",
StatusCode: http.StatusInternalServerError,
RenderErrorPage: true,
})
}
err = page.Render(r.Context(), w)
if err != nil {
auth.server.ThrowError(w, r, hws.HWSError{
Error: err,
Message: "Failed to render error page",
StatusCode: http.StatusInternalServerError,
RenderErrorPage: true,
})
} }
return return
} }
@@ -74,23 +66,14 @@ func (auth *Authenticator[T, TX]) FreshReq(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
model, ok := getAuthorizedModel[T](r.Context()) model, ok := getAuthorizedModel[T](r.Context())
if !ok { if !ok {
page, err := auth.errorPage(http.StatusUnauthorized) err := auth.server.ThrowError(w, r, hws.HWSError{
Error: errors.New("Login required"),
Message: "Please login to view this page",
StatusCode: http.StatusUnauthorized,
RenderErrorPage: true,
})
if err != nil { if err != nil {
auth.server.ThrowError(w, r, hws.HWSError{ auth.server.ThrowFatal(w, err)
Error: err,
Message: "Failed to get valid error page",
StatusCode: http.StatusInternalServerError,
RenderErrorPage: true,
})
}
err = page.Render(r.Context(), w)
if err != nil {
auth.server.ThrowError(w, r, hws.HWSError{
Error: err,
Message: "Failed to render error page",
StatusCode: http.StatusInternalServerError,
RenderErrorPage: true,
})
} }
return return
} }