Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
}
1 change: 1 addition & 0 deletions server/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ type Config struct {
StaticFiles string
HistoryMaxRetention int
PersistenceDirectory string
InitMocks string
TLSEnable bool
TLSCertFile string
TLSKeyFile string
Expand Down
7 changes: 6 additions & 1 deletion server/mock_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package server
import (
"log/slog"
"net/http"
"os"
"strconv"

"github.com/labstack/echo/v4"
Expand All @@ -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
Expand Down
42 changes: 40 additions & 2 deletions server/services/mocks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand Down
76 changes: 73 additions & 3 deletions server/services/mocks_test.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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")

Expand All @@ -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,
Expand Down
Loading