diff --git a/main.go b/main.go index b5599b4..90d9f66 100644 --- a/main.go +++ b/main.go @@ -40,6 +40,9 @@ func parseConfig() (c config.Config) { fs.StringVar(&c.StaticFiles, "static-files", "client", "Location of the static files to serve (index.html, etc.)") fs.IntVar(&c.HistoryMaxRetention, "history-retention", 0, "Maximum number of calls to keep in the history per session (0 = no limit)") fs.StringVar(&c.PersistenceDirectory, "persistence-directory", "", "If defined, the directory where the sessions will be synchronized") + fs.StringVar(&c.InitMocks, "init-mocks", "", + "If set, load mocks from a YAML file (POST /mocks format) into a session at startup; "+ + "mutually exclusive with --persistence-directory") fs.BoolVar(&c.TLSEnable, "tls-enable", false, "Enable TLS using the provided certificate") fs.StringVar(&c.TLSCertFile, "tls-cert-file", "/etc/smocker/tls/certs/cert.pem", "Path to TLS certificate file ") fs.StringVar(&c.TLSKeyFile, "tls-private-key-file", "/etc/smocker/tls/private/key.pem", "Path to TLS key file") @@ -104,5 +107,15 @@ func setupLogger(logLevel string) { func main() { c := parseConfig() setupLogger(c.LogLevel) + + // Loading mocks at startup and persisting sessions are opposite intents: a seed file is a + // fixed, read-only starting point, while persistence resumes (and rewrites) whatever state it + // last held. Both define the boot state, so allowing both would be ambiguous on restart. + if c.InitMocks != "" && c.PersistenceDirectory != "" { + slog.Error("--init-mocks and --persistence-directory are mutually exclusive: " + + "seed mocks from a file (ephemeral) or persist sessions to a directory, not both") + os.Exit(1) + } + server.Serve(c) } diff --git a/server/config/config.go b/server/config/config.go index b2212e7..68c11db 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -8,6 +8,7 @@ type Config struct { StaticFiles string HistoryMaxRetention int PersistenceDirectory string + InitMocks string TLSEnable bool TLSCertFile string TLSKeyFile string diff --git a/server/mock_server.go b/server/mock_server.go index ebc9c69..7248198 100644 --- a/server/mock_server.go +++ b/server/mock_server.go @@ -3,6 +3,7 @@ package server import ( "log/slog" "net/http" + "os" "strconv" "github.com/labstack/echo/v4" @@ -18,7 +19,11 @@ func NewMockServer(cfg config.Config) (*http.Server, services.Mocks) { if err != nil { slog.Error("Unable to load sessions", "error", err) } - mockServices := services.NewMocks(sessions, cfg.HistoryMaxRetention, persistence) + mockServices, err := services.NewMocks(sessions, cfg.HistoryMaxRetention, persistence, cfg.InitMocks) + if err != nil { + slog.Error("Unable to load initial mocks", "error", err, "init-mocks", cfg.InitMocks) + os.Exit(1) + } mockServerEngine.HideBanner = true mockServerEngine.HidePort = true diff --git a/server/services/mocks.go b/server/services/mocks.go index c762cf5..57779cf 100644 --- a/server/services/mocks.go +++ b/server/services/mocks.go @@ -2,12 +2,15 @@ package services import ( "fmt" + "log/slog" + "os" "regexp" "strings" "sync" "time" "github.com/smocker-dev/smocker/server/types" + "gopkg.in/yaml.v3" ) var ( @@ -44,7 +47,7 @@ type mocks struct { persistence Persistence } -func NewMocks(sessions types.Sessions, historyRetention int, persistence Persistence) Mocks { +func NewMocks(sessions types.Sessions, historyRetention int, persistence Persistence, initMocksFile string) (Mocks, error) { s := &mocks{ sessions: types.Sessions{}, historyRetention: historyRetention, @@ -53,7 +56,42 @@ func NewMocks(sessions types.Sessions, historyRetention int, persistence Persist if sessions != nil { s.sessions = sessions } - return s + if initMocksFile != "" { + if err := s.seedFromFile(initMocksFile); err != nil { + return nil, err + } + } + return s, nil +} + +// seedFromFile loads mocks from a YAML file (the POST /mocks format) into a fresh "init-mocks" +// session. Backs the --init-mocks startup flag; the mocks are never written back. +func (s *mocks) seedFromFile(path string) error { + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + + var loaded types.Mocks + if err := yaml.NewDecoder(file).Decode(&loaded); err != nil { + return err + } + for _, mock := range loaded { + if err := mock.Validate(); err != nil { + return err + } + } + + sessionID := s.NewSession("init-mocks").ID + for _, mock := range loaded { + if _, err := s.AddMock(sessionID, mock); err != nil { + return err + } + } + + slog.Info("Loaded initial mocks", "count", len(loaded), "init-mocks", path) + return nil } func (s *mocks) AddMock(sessionID string, newMock *types.Mock) (*types.Mock, error) { diff --git a/server/services/mocks_test.go b/server/services/mocks_test.go index 55d4bd7..828373e 100644 --- a/server/services/mocks_test.go +++ b/server/services/mocks_test.go @@ -1,12 +1,23 @@ package services import ( + "os" + "path/filepath" "testing" "github.com/smocker-dev/smocker/server/types" "gopkg.in/yaml.v3" ) +func newTestMocks(t *testing.T) Mocks { + t.Helper() + svc, err := NewMocks(nil, 0, NewPersistence(""), "") + if err != nil { + t.Fatalf("NewMocks: %v", err) + } + return svc +} + func mockFromYAML(t *testing.T, s string) *types.Mock { t.Helper() var m types.Mock @@ -16,10 +27,69 @@ func mockFromYAML(t *testing.T, s string) *types.Mock { return &m } +// TestNewMocksInitFile covers the --init-mocks path: a YAML file of mocks (the POST /mocks format) +// is loaded into a fresh "init-mocks" session when the service is constructed. +func TestNewMocksInitFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "mocks.yml") + content := ` +- request: + method: GET + path: /hello + response: + status: 200 + body: hi +- request: + method: GET + path: /health + response: + status: 204 +` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + svc, err := NewMocks(nil, 0, NewPersistence(""), path) + if err != nil { + t.Fatalf("NewMocks: %v", err) + } + + session := svc.GetLastSession() + if session == nil { + t.Fatal("expected a seeded session") + } + if session.Name != "init-mocks" { + t.Fatalf("expected the seeded session to be named %q, got %q", "init-mocks", session.Name) + } + mocks, err := svc.GetMocks(session.ID) + if err != nil { + t.Fatalf("GetMocks: %v", err) + } + if len(mocks) != 2 { + t.Fatalf("expected 2 seeded mocks, got %d", len(mocks)) + } +} + +func TestNewMocksInitFileMissing(t *testing.T) { + if _, err := NewMocks(nil, 0, NewPersistence(""), filepath.Join(t.TempDir(), "nope.yml")); err == nil { + t.Fatal("expected an error for a missing file") + } +} + +func TestNewMocksInitFileInvalid(t *testing.T) { + path := filepath.Join(t.TempDir(), "mocks.yml") + // A mock with neither response, proxy, nor dynamic_response fails Validate(). + if err := os.WriteFile(path, []byte("- request: {path: /a}\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := NewMocks(nil, 0, NewPersistence(""), path); err == nil { + t.Fatal("expected a validation error for an incomplete mock") + } +} + // TestUpdateAndDeleteMock covers the happy path for the edit/delete feature (issues #299/#266/#303) // while the session history is still empty. func TestUpdateAndDeleteMock(t *testing.T) { - svc := NewMocks(nil, 0, NewPersistence("")) + svc := newTestMocks(t) session := svc.NewSession("edit") added, err := svc.AddMock( @@ -71,7 +141,7 @@ func TestUpdateAndDeleteMock(t *testing.T) { // TestDeleteSession covers removing a single session and the not-found path. func TestDeleteSession(t *testing.T) { - svc := NewMocks(nil, 0, NewPersistence("")) + svc := newTestMocks(t) keep := svc.NewSession("keep") drop := svc.NewSession("drop") @@ -95,7 +165,7 @@ func TestDeleteSession(t *testing.T) { // TestEditForbiddenOnceCalled locks the maintainers' position: once the session has received calls, // mocks are append-only (history entries are tied to the mock that answered them). func TestEditForbiddenOnceCalled(t *testing.T) { - svc := NewMocks(nil, 0, NewPersistence("")) + svc := newTestMocks(t) session := svc.NewSession("called") added, err := svc.AddMock( session.ID,