diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..a35bfd6 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @ashishkgGoogle @poojajoshi2 @Deepa-Mulchandani @tanyamadaan diff --git a/.github/workflows/beckn_ci.yml b/.github/workflows/beckn_ci.yml new file mode 100644 index 0000000..df6c105 --- /dev/null +++ b/.github/workflows/beckn_ci.yml @@ -0,0 +1,88 @@ +name: CI/CD Pipeline + +on: + pull_request: + branches: + - beckn-onix-v1.0-develop + +env: + APP_DIRECTORY: "shared/plugin" # Root directory to start searching from + +jobs: + lint_and_test: + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + timeout-minutes: 10 # Increased timeout due to additional steps + steps: + # 1. Checkout the code from the test branch (triggered by PR) + - name: Checkout code + uses: actions/checkout@v4 + + # 2. Set up Go environment + - name: Set up Go 1.24.0 + uses: actions/setup-go@v4 + with: + go-version: '1.24.0' + + # 3. Install golangci-lint + - name: Install golangci-lint + run: | + go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + + # 4. Run golangci-lint on the entire repo, starting from the root directory + - name: Run golangci-lint + run: | + golangci-lint run ./... # This will lint all Go files in the repo and subdirectories + + # 5. Run unit tests with coverage in the entire repository + - name: Run unit tests with coverage + run: | + # Create a directory to store coverage files + mkdir -p $GITHUB_WORKSPACE/coverage_files + + # Find all *_test.go files and run `go test` for each + find ./ -type f -name '*_test.go' | while read test_file; do + # Get the directory of the test file + test_dir=$(dirname "$test_file") + # Get the name of the Go file associated with the test + go_file="${test_file/_test.go/.go}" + + # Run tests and store coverage for each Go file in a separate file + echo "Running tests in $test_dir for $go_file" + go test -v -coverprofile=$GITHUB_WORKSPACE/coverage_files/coverage_$(basename "$go_file" .go).out $test_dir + done + + # 6. List the generated coverage files for debugging purposes + #- name: List coverage files + #run: | + #echo "Listing all generated coverage files:" + #ls -l $GITHUB_WORKSPACE/coverage_files/ + + # 7. Check coverage for each generated coverage file + - name: Check coverage for each test file + run: | + # Loop through each coverage file in the coverage_files directory + for coverage_file in $GITHUB_WORKSPACE/coverage_files/coverage_*.out; do + echo "Checking coverage for $coverage_file" + + # Get the coverage percentage for each file + coverage=$(go tool cover -func=$coverage_file | grep total | awk '{print $3}' | sed 's/%//') + echo "Coverage for $coverage_file: $coverage%" + + # If coverage is below threshold (90%), fail the job + if (( $(echo "$coverage < 80" | bc -l) )); then + echo "Coverage for $coverage_file is below 90%. Failing the job." + exit 1 + fi + done + + # 7. Build the Go code + #- name: Build Go code + # run: | + # go build -o myapp ${{ env.APP_DIRECTORY }}/... + # if [ ! -f myapp ]; then + # echo "Build failed: myapp executable was not created." + # exit 1 + # else + # echo "Build succeeded: myapp executable created." + # fi diff --git a/.github/workflows/beckn_ci_test.yml b/.github/workflows/beckn_ci_test.yml new file mode 100644 index 0000000..e8d9ae6 --- /dev/null +++ b/.github/workflows/beckn_ci_test.yml @@ -0,0 +1,85 @@ +name: CI/CD Test Pipeline + +on: + pull_request: + branches: + - beckn-onix-v1.0-develop + +env: + APP_DIRECTORY: "shared/plugin" + +jobs: + lint_and_test: + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + timeout-minutes: 10 + outputs: + coverage_ok: ${{ steps.coverage_check.outputs.coverage_ok }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go 1.24.0 + uses: actions/setup-go@v4 + with: + go-version: '1.24.0' + + - name: Install golangci-lint + run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + + - name: Run golangci-lint + run: golangci-lint run ./... + + - name: Run unit tests with coverage + run: | + mkdir -p $GITHUB_WORKSPACE/coverage_files + test_files=$(find ./ -type f -name '*_test.go') + if [ -z "$test_files" ]; then + echo "No test cases found. Skipping." + exit 0 + fi + for test_file in $test_files; do + test_dir=$(dirname "$test_file") + go_file="${test_file/_test.go/.go}" + echo "Running tests in $test_dir for $go_file" + go test -v -coverprofile=$GITHUB_WORKSPACE/coverage_files/coverage_$(basename "$go_file" .go).out $test_dir || echo "Tests failed, but continuing." + done + + - name: Check coverage for each test file + id: coverage_check + run: | + echo "coverage_ok=true" >> $GITHUB_OUTPUT + coverage_files=$(find $GITHUB_WORKSPACE/coverage_files -name "coverage_*.out") + if [ -z "$coverage_files" ]; then + echo "No coverage files found. Skipping coverage check." + exit 0 + fi + for coverage_file in $coverage_files; do + echo "Checking coverage for $coverage_file" + coverage=$(go tool cover -func=$coverage_file | grep total | awk '{print $3}' | sed 's/%//') + echo "Coverage: $coverage%" + if (( $(echo "$coverage < 90" | bc -l) )); then + echo "coverage_ok=false" >> $GITHUB_OUTPUT + break + fi + done + + require_exception_approval: + needs: lint_and_test + if: needs.lint_and_test.outputs.coverage_ok == 'false' + runs-on: ubuntu-latest + environment: + name: coverage-exception + url: https://your-coverage-dashboard.com # Optional + steps: + - name: Manual approval required + run: echo "Coverage < 90%. Approval required to continue." + + proceed_with_merge: + needs: [lint_and_test, require_exception_approval] + if: | + needs.lint_and_test.outputs.coverage_ok == 'true' || success() + runs-on: ubuntu-latest + steps: + - name: Proceed with merge + run: echo "Coverage requirement met or exception approved. Merge allowed." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cef2efd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: Go CI +on: + pull_request: + branches: + - beck-onix-v1.0-develop + - beck-onix-v1.0 + push: + branches: + - beck-onix-v1.0-develop + - beck-onix-v1.0 +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Go + uses: actions/setup-go@v2 + with: + go-version: '1.20' + - name: Install dependencies + run: go mod tidy + - name: Run tests + run: go test -coverprofile=coverage.out ./... + - name: Check coverage + run: | + coverage=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//') + if (( $(echo "$coverage < 90" | bc -l) )); then + echo "Coverage is below 90%" + exit 1 + fi + - name: Run golangci-lint + run: golangci-lint run + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + files: ./coverage.out diff --git a/.gitignore b/.gitignore index b546895..12018c1 100644 --- a/.gitignore +++ b/.gitignore @@ -129,3 +129,13 @@ dist .yarn/build-state.yml .yarn/install-state.gz .pnp.* + +# Ignore compiled shared object files +*.so + +# Ignore coverage output files +coverage.out +coverage.html + +# Ignore the schema directory used for testing +/schemas/ \ No newline at end of file diff --git a/Dockerfile.adapter b/Dockerfile.adapter new file mode 100644 index 0000000..a8eb006 --- /dev/null +++ b/Dockerfile.adapter @@ -0,0 +1,25 @@ +FROM golang:1.24-bullseye AS builder + +WORKDIR /app +COPY cmd/adapter ./cmd/adapter +COPY core/ ./core +COPY pkg/ ./pkg +COPY go.mod . +COPY go.sum . +RUN go mod download + +RUN go build -o server cmd/adapter/main.go + +# Create a minimal runtime image +FROM cgr.dev/chainguard/wolfi-base +# ✅ Alpine is removed; using minimal Debian +WORKDIR /app + +# Copy only the built binary and plugin +COPY --from=builder /app/server . + +# Expose port 8080 +EXPOSE 8080 + +# Run the Go server with the config flag from environment variable. +CMD ["sh", "-c", "./server --config=${CONFIG_FILE}"] \ No newline at end of file diff --git a/cmd/adapter/main.go b/cmd/adapter/main.go new file mode 100644 index 0000000..1c88c60 --- /dev/null +++ b/cmd/adapter/main.go @@ -0,0 +1,181 @@ +package main + +import ( + "context" + "flag" + "fmt" + "net" + "net/http" + "os" + "strings" + "sync" + "time" + + "gopkg.in/yaml.v2" + + "github.com/beckn/beckn-onix/core/module" + "github.com/beckn/beckn-onix/core/module/handler" + "github.com/beckn/beckn-onix/pkg/log" + "github.com/beckn/beckn-onix/pkg/plugin" +) + +// Config struct holds all configurations. +type Config struct { + AppName string `yaml:"appName"` + Log log.Config `yaml:"log"` + PluginManager *plugin.ManagerConfig `yaml:"pluginManager"` + Modules []module.Config `yaml:"modules"` + HTTP httpConfig `yaml:"http"` +} + +type httpConfig struct { + Port string `yaml:"port"` + Timeouts timeoutConfig `yaml:"timeout"` +} + +type timeoutConfig struct { + Read time.Duration `yaml:"read"` + Write time.Duration `yaml:"write"` + Idle time.Duration `yaml:"idle"` +} + +var configPath string +var runFunc = run + +func main() { + // Define and parse command-line flags. + flag.StringVar(&configPath, "config", "../../config/onix/adapter.yaml", "Path to the configuration file") + flag.Parse() + + // Use custom log for initial setup messages. + log.Infof(context.Background(), "Starting application with config: %s", configPath) + + // Run the application within a context. + if err := runFunc(context.Background(), configPath); err != nil { + log.Fatalf(context.Background(), err, "Application failed: %v", err) + } + log.Infof(context.Background(), "Application finished") +} + +// initConfig loads and validates the configuration. +func initConfig(ctx context.Context, path string) (*Config, error) { + // Open the configuration file. + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("could not open config file: %w", err) + } + defer file.Close() + + // Decode the YAML configuration. + var cfg Config + if err := yaml.NewDecoder(file).Decode(&cfg); err != nil { + return nil, fmt.Errorf("could not decode config: %w", err) + } + log.Debugf(ctx, "Read config: %#v", cfg) + // Validate the configuration. + if err := validateConfig(&cfg); err != nil { + return nil, fmt.Errorf("invalid config: %w", err) + } + + return &cfg, nil +} + +// validateConfig validates the configuration. +func validateConfig(cfg *Config) error { + if strings.TrimSpace(cfg.AppName) == "" { + return fmt.Errorf("missing app name") + } + if strings.TrimSpace(cfg.HTTP.Port) == "" { + return fmt.Errorf("missing port") + } + return nil +} + +// newServer creates and initializes the HTTP server. +func newServer(ctx context.Context, mgr handler.PluginManager, cfg *Config) (http.Handler, error) { + mux := http.NewServeMux() + err := module.Register(ctx, cfg.Modules, mux, mgr) + if err != nil { + return nil, fmt.Errorf("failed to register modules: %w", err) + } + return mux, nil +} + +var newManagerFunc = plugin.NewManager +var newServerFunc = newServer + +// run encapsulates the application logic. +func run(ctx context.Context, configPath string) error { + closers := []func(){} + // Initialize configuration and logger. + cfg, err := initConfig(ctx, configPath) + if err != nil { + return fmt.Errorf("failed to initialize config: %w", err) + } + log.Infof(ctx, "Initializing logger with config: %+v", cfg.Log) + if err := log.InitLogger(cfg.Log); err != nil { + return fmt.Errorf("failed to initialize logger: %w", err) + } + + // Initialize plugin manager. + log.Infof(ctx, "Initializing plugin manager") + mgr, closer, err := newManagerFunc(ctx, cfg.PluginManager) + if err != nil { + return fmt.Errorf("failed to create plugin manager: %w", err) + } + closers = append(closers, closer) + log.Debug(ctx, "Plugin manager loaded.") + + // Initialize HTTP server. + log.Infof(ctx, "Initializing HTTP server") + srv, err := newServerFunc(ctx, mgr, cfg) + if err != nil { + return fmt.Errorf("failed to initialize server: %w", err) + } + + // Configure HTTP server. + httpServer := &http.Server{ + Addr: net.JoinHostPort("", cfg.HTTP.Port), + Handler: srv, + ReadTimeout: cfg.HTTP.Timeouts.Read * time.Second, + WriteTimeout: cfg.HTTP.Timeouts.Write * time.Second, + IdleTimeout: cfg.HTTP.Timeouts.Idle * time.Second, + } + + // Start HTTP server. + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + log.Infof(ctx, "Server listening on %s", httpServer.Addr) + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Errorf(ctx, fmt.Errorf("http server ListenAndServe: %w", err), "error listening and serving") + } + }() + + // Handle shutdown. + shutdown(ctx, httpServer, &wg, closers) + wg.Wait() + log.Infof(ctx, "Server shutdown complete") + return nil +} + +// shutdown handles server shutdown. +func shutdown(ctx context.Context, httpServer *http.Server, wg *sync.WaitGroup, closers []func()) { + wg.Add(1) + go func() { + defer wg.Done() + <-ctx.Done() + log.Infof(ctx, "Shutting down server...") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := httpServer.Shutdown(shutdownCtx); err != nil { + log.Errorf(ctx, fmt.Errorf("http server Shutdown: %w", err), "error shutting down http server") + } + + // Call all closer functions. + for _, closer := range closers { + closer() + } + }() +} diff --git a/cmd/adapter/main_test.go b/cmd/adapter/main_test.go new file mode 100644 index 0000000..e8b6658 --- /dev/null +++ b/cmd/adapter/main_test.go @@ -0,0 +1,429 @@ +package main + +import ( + "context" + "errors" + "flag" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/beckn/beckn-onix/core/module" + "github.com/beckn/beckn-onix/core/module/handler" + "github.com/beckn/beckn-onix/pkg/plugin" + "github.com/beckn/beckn-onix/pkg/plugin/definition" + "github.com/stretchr/testify/mock" +) + +// MockPluginManager implements handler.PluginManager for testing. +type MockPluginManager struct { + mock.Mock +} + +// Middleware returns a middleware function based on the provided configuration. +func (m *MockPluginManager) Middleware(ctx context.Context, cfg *plugin.Config) (func(http.Handler) http.Handler, error) { + return nil, nil +} + +// SignValidator returns a mock implementation of the Verifier interface. +func (m *MockPluginManager) SignValidator(ctx context.Context, cfg *plugin.Config) (definition.SignValidator, error) { + return nil, nil +} + +// Validator returns a mock implementation of the SchemaValidator interface. +func (m *MockPluginManager) Validator(ctx context.Context, cfg *plugin.Config) (definition.SchemaValidator, error) { + return nil, nil +} + +// Router returns a mock implementation of the Router interface. +func (m *MockPluginManager) Router(ctx context.Context, cfg *plugin.Config) (definition.Router, error) { + return nil, nil +} + +// Publisher returns a mock implementation of the Publisher interface. +func (m *MockPluginManager) Publisher(ctx context.Context, cfg *plugin.Config) (definition.Publisher, error) { + return nil, nil +} + +// Signer returns a mock implementation of the Signer interface. +func (m *MockPluginManager) Signer(ctx context.Context, cfg *plugin.Config) (definition.Signer, error) { + return nil, nil +} + +// Step returns a mock implementation of the Step interface. +func (m *MockPluginManager) Step(ctx context.Context, cfg *plugin.Config) (definition.Step, error) { + return nil, nil +} + +// Cache returns a mock implementation of the Cache interface. +func (m *MockPluginManager) Cache(ctx context.Context, cfg *plugin.Config) (definition.Cache, error) { + return nil, nil +} + +// KeyManager returns a mock implementation of the KeyManager interface. +func (m *MockPluginManager) KeyManager(ctx context.Context, cache definition.Cache, rLookup definition.RegistryLookup, cfg *plugin.Config) (definition.KeyManager, error) { + return nil, nil +} + +// SchemaValidator returns a mock implementation of the SchemaValidator interface. +func (m *MockPluginManager) SchemaValidator(ctx context.Context, cfg *plugin.Config) (definition.SchemaValidator, error) { + return nil, nil +} + +// mockRun is a mock implementation of the `run` function, simulating a successful run. +func mockRun(ctx context.Context, configPath string) error { + return nil // Simulate a successful run +} + +// TestMainFunction tests the main function execution, including command-line argument parsing. +func TestMainFunction(t *testing.T) { + // Backup original run function and restore it after test + origRun := runFunc + defer func() { runFunc = origRun }() + runFunc = mockRun + + origArgs := os.Args + defer func() { os.Args = origArgs }() + + // Set mock command-line arguments + os.Args = []string{"cmd", "-config=../../config/test-config.yaml"} + + fs := flag.NewFlagSet("test", flag.ExitOnError) + fs.StringVar(&configPath, "config", "../../config/clientSideHandler-config.yaml", "Path to the configuration file") + + if err := fs.Parse(os.Args[1:]); err != nil { + t.Fatalf("Failed to parse flags: %v", err) + } + main() +} + +func TestRunSuccess(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + configPath := "../test/validConfig.yaml" + + // Mock dependencies + originalNewManager := newManagerFunc + newManagerFunc = func(ctx context.Context, cfg *plugin.ManagerConfig) (*plugin.Manager, func(), error) { + return &plugin.Manager{}, func() {}, nil + } + defer func() { newManagerFunc = originalNewManager }() + + originalNewServer := newServerFunc + newServerFunc = func(ctx context.Context, mgr handler.PluginManager, cfg *Config) (http.Handler, error) { + return http.NewServeMux(), nil + } + defer func() { newServerFunc = originalNewServer }() + + if err := run(ctx, filepath.Clean(configPath)); err != nil { + t.Errorf("Expected no error, but got: %v", err) + } +} + +// TestRunFailure validates failure scenarios for the run function. +func TestRunFailure(t *testing.T) { + tests := []struct { + name string + configData string + mockMgr func() (*MockPluginManager, func(), error) + mockLogger func(cfg *Config) error + mockServer func(ctx context.Context, mgr handler.PluginManager, cfg *Config) (http.Handler, error) + expectedErr string + }{ + { + name: "Invalid Config File", + configData: "invalid_config.yaml", + mockMgr: func() (*MockPluginManager, func(), error) { + return &MockPluginManager{}, func() {}, nil + }, + mockLogger: func(cfg *Config) error { + return nil + }, + mockServer: func(ctx context.Context, mgr handler.PluginManager, cfg *Config) (http.Handler, error) { + return nil, errors.New("failed to start server") + }, + expectedErr: "failed to initialize config: invalid config: missing app name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + testFilePath := tt.configData + mockConfig := `invalid: "config"` + err := os.WriteFile(testFilePath, []byte(mockConfig), 0644) + if err != nil { + t.Errorf("Failed to create test config file: %v", err) + } + defer os.Remove(testFilePath) + + // Mock dependencies + originalNewManager := newManagerFunc + // newManagerFunc = func(ctx context.Context, cfg *plugin.ManagerConfig) (*plugin.Manager, func(), error) { + // return tt.mockMgr() + // } + newManagerFunc = nil + defer func() { newManagerFunc = originalNewManager }() + + originalNewServer := newServerFunc + newServerFunc = func(ctx context.Context, mgr handler.PluginManager, cfg *Config) (http.Handler, error) { + return tt.mockServer(ctx, mgr, cfg) + } + defer func() { newServerFunc = originalNewServer }() + + // Run function + err = run(ctx, testFilePath) + if err == nil { + t.Errorf("Expected error, but got nil") + } else if err.Error() != tt.expectedErr { + t.Errorf("Expected error '%s', but got '%s'", tt.expectedErr, err.Error()) + } + }) + } +} + +// TestInitConfigSuccess tests the successful initialization of the config. +func TestInitConfigSuccess(t *testing.T) { + tests := []struct { + name string + configData string + }{ + { + name: "Valid Config", + configData: ` +appName: "TestApp" +http: + port: "8080" + timeout: + read: 5 + write: 5 + idle: 10 +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + configPath := "test_config_success.yaml" + defer os.Remove(configPath) + + err := os.WriteFile(configPath, []byte(tt.configData), 0644) + if err != nil { + t.Errorf("Failed to create test config file: %v", err) + } + + _, err = initConfig(context.Background(), configPath) + if err != nil { + t.Errorf("Expected no error, but got: %v", err) + } + }) + } +} + +// TestInitConfigFailure tests failure scenarios for config initialization. +func TestInitConfigFailure(t *testing.T) { + tests := []struct { + name string + configData string + expectedErr string + }{ + { + name: "Invalid YAML Format", + configData: `appName: "TestApp"\nhttp: { invalid_yaml }`, + expectedErr: "could not decode config", + }, + { + name: "Missing Required Fields", + configData: `appName: ""\nhttp:\n timeout:\n read: 5\n`, + expectedErr: "could not decode config: yaml: did not find expected key", + }, + { + name: "Non-Existent File", + configData: "", + expectedErr: "could not open config file", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + configPath := "test_config_failure.yaml" + + if tt.configData != "" { + err := os.WriteFile(configPath, []byte(tt.configData), 0644) + if err != nil { + t.Errorf("Failed to create test config file: %v", err) + } + defer os.Remove(configPath) + } else { + // Ensure file does not exist for non-existent file test + os.Remove(configPath) + } + + _, err := initConfig(context.Background(), configPath) + if err == nil { + t.Errorf("Expected error but got nil") + } else if !strings.Contains(err.Error(), tt.expectedErr) { + t.Errorf("Expected error containing '%s', but got '%s'", tt.expectedErr, err.Error()) + } + }) + } +} + +// TestNewServerSuccess tests successful server creation. +func TestNewServerSuccess(t *testing.T) { + tests := []struct { + name string + modules []module.Config + }{ + { + name: "Successful server creation with no modules", + modules: []module.Config{}, // No modules to simplify the test + }, + } + + mockMgr := new(MockPluginManager) // Mocking PluginManager + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &Config{ + Modules: tt.modules, + HTTP: httpConfig{ + Port: "8080", + Timeouts: timeoutConfig{ + Read: 5, + Write: 5, + Idle: 10, + }, + }, + } + + handler, err := newServer(context.Background(), mockMgr, cfg) + + if err != nil { + t.Errorf("Expected no error, but got: %v", err) + } + if handler == nil { + t.Errorf("Expected handler to be non-nil, but got nil") + } + }) + } +} + +// TestNewServerFailure tests failure scenarios when creating a server. +func TestNewServerFailure(t *testing.T) { + tests := []struct { + name string + modules []module.Config + }{ + { + name: "Module registration failure", + modules: []module.Config{ + { + Name: "InvalidModule", + Path: "/invalid", + }, + }, + }, + } + + mockMgr := new(MockPluginManager) // Mocking PluginManager + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &Config{ + Modules: tt.modules, + HTTP: httpConfig{ + Port: "8080", + Timeouts: timeoutConfig{ + Read: 5, + Write: 5, + Idle: 10, + }, + }, + } + + handler, err := newServer(context.Background(), mockMgr, cfg) + + if err == nil { + t.Errorf("Expected an error, but got nil") + } + if handler != nil { + t.Errorf("Expected handler to be nil, but got a non-nil value") + } + }) + } +} + +// TestValidateConfigSuccess tests validation of a correct config. +func TestValidateConfigSuccess(t *testing.T) { + tests := []struct { + name string + cfg Config + }{ + { + name: "Valid Config", + cfg: Config{ + AppName: "TestApp", + HTTP: httpConfig{ + Port: "8080", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateConfig(&tt.cfg) + if err != nil { + t.Errorf("Expected no error, but got: %v", err) + } + }) + } +} + +// TestValidateConfigFailure tests validation failures for incorrect config. +func TestValidateConfigFailure(t *testing.T) { + tests := []struct { + name string + cfg Config + expectedErr string + }{ + { + name: "Missing AppName", + cfg: Config{ + AppName: "", + HTTP: httpConfig{ + Port: "8080", + }, + }, + expectedErr: "missing app name", + }, + { + name: "Missing Port", + cfg: Config{ + AppName: "TestApp", + HTTP: httpConfig{ + Port: "", + }, + }, + expectedErr: "missing port", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateConfig(&tt.cfg) + if err == nil { + t.Errorf("Expected error '%s', but got nil", tt.expectedErr) + } else if err.Error() != tt.expectedErr { + t.Errorf("Expected error '%s', but got '%s'", tt.expectedErr, err.Error()) + } + }) + } +} diff --git a/cmd/test/validConfig.yaml b/cmd/test/validConfig.yaml new file mode 100644 index 0000000..5497753 --- /dev/null +++ b/cmd/test/validConfig.yaml @@ -0,0 +1,37 @@ +appName: "testAdapter" +log: + level: debug + destinations: + - type: stdout + context_keys: + - transaction_id + - message_id +http: + port: 8080 + timeout: + read: 30 + write: 30 + idle: 30 +plugin: + root: "/mock/plugins" + pluginZipPath: "/mock/plugins/plugins_bundle.zip" + plugins: + - testPlugin1 + - testPlugin2 +modules: + - name: testModule + type: transaction + path: /testPath + targetType: msgQ + plugin: + schemaValidator: + id: testValidator + publisher: + id: testPublisher + config: + project: test-project + topic: test-topic + router: + id: testRouter + config: + routingConfigPath: "/mock/configs/testRouting-config.yaml" diff --git a/config/onix-bap/adapter.yaml b/config/onix-bap/adapter.yaml new file mode 100644 index 0000000..dda19fc --- /dev/null +++ b/config/onix-bap/adapter.yaml @@ -0,0 +1,98 @@ +appName: "onix" +log: + level: debug + destinations: + - type: stdout + contextKeys: + - transaction_id + - message_id + - subscriber_id + - module_id +http: + port: 8080 + timeout: + read: 30 + write: 30 + idle: 30 +pluginManager: + root: /app/plugins + remoteRoot: /mnt/gcs/plugins/plugins_bundle.zip +modules: + - name: bapTxnReciever + path: /bap/reciever/ + handler: + type: std + role: bap + registryUrl: http://localhost:8080/reg + plugins: + keyManager: + id: secretskeymanager + config: + projectID: ${projectID} + cache: + id: redis + config: + addr: 192.168.1.1:6379 + schemaValidator: + id: schemavalidator + config: + schemaDir: /mnt/gcs/configs/schemas + signValidator: + id: signvalidator + publisher: + id: publisher + config: + project: ${projectID} + topic: bapNetworkReciever + router: + id: router + config: + routingConfigPath: /mnt/gcs/configs/bapTxnReciever-routing.yaml + middleware: + - id: reqpreprocessor + config: + uuidKeys: transaction_id,message_id + role: bap + steps: + - validateSign + - addRoute + - validateSchema + - name: bapTxnCaller + path: /bap/caller/ + handler: + type: std + registryUrl: http://localhost:8080/reg + role: bap + plugins: + keyManager: + id: secretskeymanager + config: + projectID: ${projectID} + cache: + id: redis + config: + addr: 192.168.1.1:6379 + schemaValidator: + id: schemavalidator + config: + schemaDir: /mnt/gcs/configs/schemas + signer: + id: signer + publisher: + id: publisher + config: + project: ${projectID} + topic: bapNetworkReciever + router: + id: router + config: + routingConfigPath: /mnt/gcs/configs/bapTxnCaller-routing.yaml + middleware: + - id: reqpreprocessor + config: + uuidKeys: transaction_id,message_id + role: bap + steps: + - validateSchema + - addRoute + - sign \ No newline at end of file diff --git a/config/onix-bap/bapTxnCaller-routing.yaml b/config/onix-bap/bapTxnCaller-routing.yaml new file mode 100644 index 0000000..b1d5a44 --- /dev/null +++ b/config/onix-bap/bapTxnCaller-routing.yaml @@ -0,0 +1,25 @@ +routingRules: + - domain: "ONDC:TRV10" + version: "2.0.0" + routingType: "bpp" + target: + url: "https://gateway.example.com" + endpoints: + - search + - domain: "ONDC:TRV10" + version: "2.0.0" + routingType: "bpp" + endpoints: + - select + - init + - confirm + - status + - cancel + - domain: "ONDC:TRV12" + version: "2.0.0" + routingType: "bpp" + endpoints: + - select + - init + - confirm + - status \ No newline at end of file diff --git a/config/onix-bap/bapTxnReciever-routing.yaml b/config/onix-bap/bapTxnReciever-routing.yaml new file mode 100644 index 0000000..ca4a478 --- /dev/null +++ b/config/onix-bap/bapTxnReciever-routing.yaml @@ -0,0 +1,20 @@ +routingRules: + - domain: "ONDC:TRV10" + version: "2.0.0" + routingType: "url" + target: + url: "https://services-backend/trv/v1" + endpoints: + - on_select + - on_init + - on_confirm + - on_status + - on_update + - on_cancel + - domain: "ONDC:TRV10" + version: "2.0.0" + routingType: "msgq" + target: + topic_id: "trv_topic_id1" + endpoints: + - on_search \ No newline at end of file diff --git a/config/onix-bap/plugin.yaml b/config/onix-bap/plugin.yaml new file mode 100644 index 0000000..4c2bb72 --- /dev/null +++ b/config/onix-bap/plugin.yaml @@ -0,0 +1,8 @@ +plugins: + - gcpAuthMdw + - nopsigner + - router + - publisher + - reqpreprocessor + - nopschemavalidator + - nopsignvalidator \ No newline at end of file diff --git a/config/onix-bpp/adapter.yaml b/config/onix-bpp/adapter.yaml new file mode 100644 index 0000000..aa3d242 --- /dev/null +++ b/config/onix-bpp/adapter.yaml @@ -0,0 +1,99 @@ +appName: "onix" +log: + level: debug + destinations: + - type: stdout + contextKeys: + - transaction_id + - message_id + - subscriber_id + - module_id +http: + port: 8080 + timeout: + read: 30 + write: 30 + idle: 30 +pluginManager: + root: /app/plugins + remoteRoot: /mnt/gcs/plugins/plugins_bundle.zip +modules: + - name: bppTxnReciever + path: /bpp/reciever/ + handler: + type: std + role: bpp + subscriberId: bpp1 + registryUrl: http://localhost:8080/reg + plugins: + keyManager: + id: secretskeymanager + config: + projectID: ${projectID} + cache: + id: redis + config: + addr: 192.168.1.1:6379 + schemaValidator: + id: schemavalidator + config: + schemaDir: /mnt/gcs/configs/schemas + signValidator: + id: signvalidator + publisher: + id: publisher + config: + project: ${projectID} + topic: bapNetworkReciever + router: + id: router + config: + routingConfigPath: /mnt/gcs/configs/bppTxnReciever-routing.yaml + middleware: + - id: reqpreprocessor + config: + uuidKeys: transaction_id,message_id + role: bpp + steps: + - validateSign + - addRoute + - validateSchema + - name: bppTxnCaller + path: /bpp/caller/ + handler: + type: std + role: bpp + registryUrl: http://localhost:8080/reg + plugins: + keyManager: + id: secretskeymanager + config: + projectID: ${projectID} + cache: + id: redis + config: + addr: 192.168.1.1:6379 + schemaValidator: + id: schemavalidator + config: + schemaDir: /mnt/gcs/configs/schemas + signer: + id: signer + publisher: + id: publisher + config: + project: ${projectID} + topic: bapNetworkReciever + router: + id: router + config: + routingConfigPath: /mnt/gcs/configs/bppTxnCaller-routing.yaml + middleware: + - id: reqpreprocessor + config: + uuidKeys: transaction_id,message_id + role: bpp + steps: + - validateSchema + - addRoute + - sign diff --git a/config/onix-bpp/bppTxnCaller-routing.yaml b/config/onix-bpp/bppTxnCaller-routing.yaml new file mode 100644 index 0000000..0d9a670 --- /dev/null +++ b/config/onix-bpp/bppTxnCaller-routing.yaml @@ -0,0 +1,23 @@ +routingRules: + - domain: "ONDC:TRV10" + version: "2.0.0" + routingType: "bap" + endpoints: + - on_search + - on_select + - on_init + - on_confirm + - on_status + - on_update + - on_cancel + - domain: "ONDC:TRV11" + version: "2.0.0" + routingType: "bap" + endpoints: + - on_search + - on_select + - on_init + - on_confirm + - on_status + - on_update + - on_cancel \ No newline at end of file diff --git a/config/onix-bpp/bppTxnReciever-routing.yaml b/config/onix-bpp/bppTxnReciever-routing.yaml new file mode 100644 index 0000000..6febce6 --- /dev/null +++ b/config/onix-bpp/bppTxnReciever-routing.yaml @@ -0,0 +1,30 @@ +routingRules: + - domain: "ONDC:TRV10" + version: "2.0.0" + routingType: "url" + target: + url: "https://services-backend/trv/v1" + endpoints: + - select + - init + - confirm + - status + - cancel + + - domain: "ONDC:TRV10" + version: "2.0.0" + routingType: "msgq" + target: + topic_id: "trv_topic_id1" + endpoints: + - search + + - domain: "ONDC:TRV11" + version: "2.0.0" + routingType: "url" + target: + url: "https://services-backend/trv/v1" + endpoints: + - select + - init + - confirm \ No newline at end of file diff --git a/config/onix-bpp/plugin.yaml b/config/onix-bpp/plugin.yaml new file mode 100644 index 0000000..4c2bb72 --- /dev/null +++ b/config/onix-bpp/plugin.yaml @@ -0,0 +1,8 @@ +plugins: + - gcpAuthMdw + - nopsigner + - router + - publisher + - reqpreprocessor + - nopschemavalidator + - nopsignvalidator \ No newline at end of file diff --git a/config/onix/adapter.yaml b/config/onix/adapter.yaml new file mode 100644 index 0000000..e3a785b --- /dev/null +++ b/config/onix/adapter.yaml @@ -0,0 +1,177 @@ +appName: "onix" +log: + level: debug + destinations: + - type: stdout + contextKeys: + - transaction_id + - message_id + - subscriber_id + - module_id +http: + port: 8080 + timeout: + read: 30 + write: 30 + idle: 30 +pluginManager: + root: /app/plugins + remoteRoot: /mnt/gcs/plugins/plugins_bundle.zip +modules: + - name: bapTxnReciever + path: /bap/reciever/ + handler: + type: std + role: bap + registryUrl: http://localhost:8080/reg + plugins: + keyManager: + id: secretskeymanager + config: + projectID: ${projectID} + cache: + id: redis + config: + addr: 10.81.192.4:6379 + schemaValidator: + id: schemavalidator + config: + schemaDir: /mnt/gcs/configs/schemas + signValidator: + id: signvalidator + publisher: + id: publisher + config: + project: ${projectID} + topic: bapNetworkReciever + router: + id: router + config: + routingConfigPath: /mnt/gcs/configs/bapTxnReciever-routing.yaml + middleware: + - id: reqpreprocessor + config: + uuidKeys: transaction_id,message_id + role: bap + steps: + - validateSign + - addRoute + - validateSchema + - name: bapTxnCaller + path: /bap/caller/ + handler: + type: std + registryUrl: http://localhost:8080/reg + role: bap + plugins: + keyManager: + id: secretskeymanager + config: + projectID: ${projectID} + cache: + id: redis + config: + addr: 192.168.1.1:6379 + schemaValidator: + id: schemavalidator + config: + schemaDir: /mnt/gcs/configs/schemas + signer: + id: signer + publisher: + id: publisher + config: + project: ${projectID} + topic: bapNetworkReciever + router: + id: router + config: + routingConfigPath: /mnt/gcs/configs/bapTxnCaller-routing.yaml + middleware: + - id: reqpreprocessor + config: + uuidKeys: transaction_id,message_id + role: bap + steps: + - validateSchema + - addRoute + - sign + - name: bppTxnReciever + path: /bpp/reciever/ + handler: + type: std + role: bpp + subscriberId: bpp1 + registryUrl: http://localhost:8080/reg + plugins: + keyManager: + id: secretskeymanager + config: + projectID: ${projectID} + cache: + id: redis + config: + addr: 192.168.1.1:6379 + schemaValidator: + id: schemavalidator + config: + schemaDir: /mnt/gcs/configs/schemas + signValidator: + id: signvalidator + publisher: + id: publisher + config: + project: ${projectID} + topic: bapNetworkReciever + router: + id: router + config: + routingConfigPath: /mnt/gcs/configs/bppTxnReciever-routing.yaml + middleware: + - id: reqpreprocessor + config: + uuidKeys: transaction_id,message_id + role: bpp + steps: + - validateSign + - addRoute + - validateSchema + - name: bppTxnCaller + path: /bpp/caller/ + handler: + type: std + role: bpp + registryUrl: http://localhost:8080/reg + plugins: + keyManager: + id: secretskeymanager + config: + projectID: ${projectID} + cache: + id: redis + config: + addr: 192.168.1.1:6379 + schemaValidator: + id: schemavalidator + config: + schemaDir: /mnt/gcs/configs/schemas + signer: + id: signer + publisher: + id: publisher + config: + project: ${projectID} + topic: bapNetworkReciever + router: + id: router + config: + routingConfigPath: /mnt/gcs/configs/bppTxnCaller-routing.yaml + middleware: + - id: reqpreprocessor + config: + uuidKeys: transaction_id,message_id + role: bpp + steps: + - validateSchema + - addRoute + - sign diff --git a/config/onix/bapTxnCaller-routing.yaml b/config/onix/bapTxnCaller-routing.yaml new file mode 100644 index 0000000..b1d5a44 --- /dev/null +++ b/config/onix/bapTxnCaller-routing.yaml @@ -0,0 +1,25 @@ +routingRules: + - domain: "ONDC:TRV10" + version: "2.0.0" + routingType: "bpp" + target: + url: "https://gateway.example.com" + endpoints: + - search + - domain: "ONDC:TRV10" + version: "2.0.0" + routingType: "bpp" + endpoints: + - select + - init + - confirm + - status + - cancel + - domain: "ONDC:TRV12" + version: "2.0.0" + routingType: "bpp" + endpoints: + - select + - init + - confirm + - status \ No newline at end of file diff --git a/config/onix/bapTxnReciever-routing.yaml b/config/onix/bapTxnReciever-routing.yaml new file mode 100644 index 0000000..ca4a478 --- /dev/null +++ b/config/onix/bapTxnReciever-routing.yaml @@ -0,0 +1,20 @@ +routingRules: + - domain: "ONDC:TRV10" + version: "2.0.0" + routingType: "url" + target: + url: "https://services-backend/trv/v1" + endpoints: + - on_select + - on_init + - on_confirm + - on_status + - on_update + - on_cancel + - domain: "ONDC:TRV10" + version: "2.0.0" + routingType: "msgq" + target: + topic_id: "trv_topic_id1" + endpoints: + - on_search \ No newline at end of file diff --git a/config/onix/bppTxnCaller-routing.yaml b/config/onix/bppTxnCaller-routing.yaml new file mode 100644 index 0000000..0d9a670 --- /dev/null +++ b/config/onix/bppTxnCaller-routing.yaml @@ -0,0 +1,23 @@ +routingRules: + - domain: "ONDC:TRV10" + version: "2.0.0" + routingType: "bap" + endpoints: + - on_search + - on_select + - on_init + - on_confirm + - on_status + - on_update + - on_cancel + - domain: "ONDC:TRV11" + version: "2.0.0" + routingType: "bap" + endpoints: + - on_search + - on_select + - on_init + - on_confirm + - on_status + - on_update + - on_cancel \ No newline at end of file diff --git a/config/onix/bppTxnReciever-routing.yaml b/config/onix/bppTxnReciever-routing.yaml new file mode 100644 index 0000000..6febce6 --- /dev/null +++ b/config/onix/bppTxnReciever-routing.yaml @@ -0,0 +1,30 @@ +routingRules: + - domain: "ONDC:TRV10" + version: "2.0.0" + routingType: "url" + target: + url: "https://services-backend/trv/v1" + endpoints: + - select + - init + - confirm + - status + - cancel + + - domain: "ONDC:TRV10" + version: "2.0.0" + routingType: "msgq" + target: + topic_id: "trv_topic_id1" + endpoints: + - search + + - domain: "ONDC:TRV11" + version: "2.0.0" + routingType: "url" + target: + url: "https://services-backend/trv/v1" + endpoints: + - select + - init + - confirm \ No newline at end of file diff --git a/config/onix/plugin.yaml b/config/onix/plugin.yaml new file mode 100644 index 0000000..4c2bb72 --- /dev/null +++ b/config/onix/plugin.yaml @@ -0,0 +1,8 @@ +plugins: + - gcpAuthMdw + - nopsigner + - router + - publisher + - reqpreprocessor + - nopschemavalidator + - nopsignvalidator \ No newline at end of file diff --git a/core/module/client/registery.go b/core/module/client/registery.go new file mode 100644 index 0000000..1fa0813 --- /dev/null +++ b/core/module/client/registery.go @@ -0,0 +1,101 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/beckn/beckn-onix/pkg/model" + "github.com/hashicorp/go-retryablehttp" +) + +// Config struct to hold configuration parameters. +type Config struct { + RegisteryURL string + RetryMax int + RetryWaitMin time.Duration + RetryWaitMax time.Duration +} + +// registryClient encapsulates the logic for calling the subscribe and lookup endpoints. +type registryClient struct { + config *Config + client *retryablehttp.Client +} + +// NewRegisteryClient creates a new instance of Client. +func NewRegisteryClient(config *Config) *registryClient { + retryClient := retryablehttp.NewClient() + + return ®istryClient{config: config, client: retryClient} +} + +// Subscribe calls the /subscribe endpoint with retry. +func (c *registryClient) Subscribe(ctx context.Context, subscription *model.Subscription) error { + subscribeURL := fmt.Sprintf("%s/subscribe", c.config.RegisteryURL) + + jsonData, err := json.Marshal(subscription) + if err != nil { + return model.NewBadReqErr(fmt.Errorf("failed to marshal subscription data: %w", err)) + } + + req, err := retryablehttp.NewRequest("POST", subscribeURL, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return fmt.Errorf("failed to send request with retry: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("subscribe request failed with status: %s", resp.Status) + } + return nil +} + +// Lookup calls the /lookup endpoint with retry and returns a slice of Subscription. +func (c *registryClient) Lookup(ctx context.Context, subscription *model.Subscription) ([]model.Subscription, error) { + lookupURL := fmt.Sprintf("%s/lookUp", c.config.RegisteryURL) + + jsonData, err := json.Marshal(subscription) + if err != nil { + return nil, model.NewBadReqErr(fmt.Errorf("failed to marshal subscription data: %w", err)) + } + + req, err := retryablehttp.NewRequest("POST", lookupURL, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request with retry: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("lookup request failed with status: %s", resp.Status) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var results []model.Subscription + err = json.Unmarshal(body, &results) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal response body: %w", err) + } + + return results, nil +} diff --git a/core/module/client/registry_test.go b/core/module/client/registry_test.go new file mode 100644 index 0000000..ac05c14 --- /dev/null +++ b/core/module/client/registry_test.go @@ -0,0 +1,231 @@ +package client + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/beckn/beckn-onix/pkg/model" + "github.com/stretchr/testify/require" +) + +// TestSubscribeSuccess verifies that the Subscribe function succeeds when the server responds with HTTP 200. +func TestSubscribeSuccess(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte("{}")); err != nil { + t.Errorf("failed to write response: %v", err) + } + })) + defer server.Close() + + client := NewRegisteryClient(&Config{ + RegisteryURL: server.URL, + RetryMax: 3, + RetryWaitMin: time.Millisecond * 100, + RetryWaitMax: time.Millisecond * 500, + }) + + subscription := &model.Subscription{ + KeyID: "test-key", + SigningPublicKey: "test-signing-key", + EncrPublicKey: "test-encryption-key", + ValidFrom: time.Now(), + ValidUntil: time.Now().Add(24 * time.Hour), + Status: "SUBSCRIBED", + } + err := client.Subscribe(context.Background(), subscription) + if err != nil { + t.Fatalf("Subscribe() failed with error: %v", err) + } +} + +// TestSubscribeFailure tests different failure scenarios using a mock client. +func TestSubscribeFailure(t *testing.T) { + tests := []struct { + name string + mockError error + }{ + { + name: "Failed subscription - Internal Server Error", + mockError: errors.New("internal server error"), + }, + { + name: "Failed subscription - Bad Request", + mockError: errors.New("bad request"), + }, + { + name: "Request Timeout", + mockError: context.DeadlineExceeded, + }, + { + name: "Network Failure", + mockError: errors.New("network failure"), + }, + { + name: "JSON Marshalling Failure", + mockError: errors.New("json marshalling failure"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := NewRegisteryClient(&Config{ + RetryMax: 1, + RetryWaitMin: 1 * time.Millisecond, + RetryWaitMax: 2 * time.Millisecond, + }) + + subscription := &model.Subscription{ + KeyID: "test-key", + SigningPublicKey: "test-signing-key", + EncrPublicKey: "test-encryption-key", + ValidFrom: time.Now(), + ValidUntil: time.Now().Add(24 * time.Hour), + Status: "SUBSCRIBED", + } + + if tt.name == "JSON Marshalling Failure" { + subscription = &model.Subscription{} // Example of an invalid object + } + + err := client.Subscribe(context.Background(), subscription) + require.Error(t, err) // Directly checking for an error since all cases should fail + }) + } +} + +// TestLookupSuccess tests successful lookup scenarios. +func TestLookupSuccess(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + response := []model.Subscription{ + { + Subscriber: model.Subscriber{ + SubscriberID: "123", + }, + KeyID: "test-key", + SigningPublicKey: "test-signing-key", + EncrPublicKey: "test-encryption-key", + ValidFrom: time.Now(), + ValidUntil: time.Now().Add(24 * time.Hour), + Status: "SUBSCRIBED", + }, + } + bodyBytes, _ := json.Marshal(response) + if _, err := w.Write(bodyBytes); err != nil { + t.Errorf("failed to write response: %v", err) + } + })) + defer server.Close() + + config := &Config{ + RegisteryURL: server.URL, + RetryMax: 1, + RetryWaitMin: 1 * time.Millisecond, + RetryWaitMax: 2 * time.Millisecond, + } + rClient := NewRegisteryClient(config) + ctx := context.Background() + subscription := &model.Subscription{ + Subscriber: model.Subscriber{ + SubscriberID: "123", + }, + KeyID: "test-key", + SigningPublicKey: "test-signing-key", + EncrPublicKey: "test-encryption-key", + ValidFrom: time.Now(), + ValidUntil: time.Now().Add(24 * time.Hour), + Status: "SUBSCRIBED", + } + + result, err := rClient.Lookup(ctx, subscription) + require.NoError(t, err) + require.NotEmpty(t, result) + require.Equal(t, subscription.Subscriber.SubscriberID, result[0].Subscriber.SubscriberID) +} + +// TestLookupFailure tests failure scenarios for the Lookup function. +func TestLookupFailure(t *testing.T) { + tests := []struct { + name string + responseBody interface{} + responseCode int + setupMock func(*httptest.Server) + }{ + { + name: "Lookup failure - non 200 status", + responseBody: "Internal Server Error", + responseCode: http.StatusInternalServerError, + }, + { + name: "Invalid JSON response", + responseBody: "Invalid JSON", + responseCode: http.StatusOK, + }, + { + name: "Server timeout", + setupMock: func(server *httptest.Server) { + server.Config.WriteTimeout = 1 * time.Millisecond // Force timeout + }, + }, + { + name: "Empty response body", + responseBody: "", + responseCode: http.StatusOK, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if tc.responseCode != 0 { // Prevent WriteHeader(0) error + w.WriteHeader(tc.responseCode) + } + if tc.responseBody != nil { + if str, ok := tc.responseBody.(string); ok { + if _, err := w.Write([]byte(str)); err != nil { + t.Errorf("failed to write response: %v", err) + } + } else { + bodyBytes, _ := json.Marshal(tc.responseBody) + if _, err := w.Write(bodyBytes); err != nil { + t.Errorf("failed to write response: %v", err) + } + } + } + })) + defer server.Close() + + if tc.setupMock != nil { + tc.setupMock(server) + } + + config := &Config{ + RegisteryURL: server.URL, + RetryMax: 0, + RetryWaitMin: 1 * time.Millisecond, + RetryWaitMax: 2 * time.Millisecond, + } + rClient := NewRegisteryClient(config) + ctx := context.Background() + subscription := &model.Subscription{ + Subscriber: model.Subscriber{}, + KeyID: "test-key", + SigningPublicKey: "test-signing-key", + EncrPublicKey: "test-encryption-key", + ValidFrom: time.Now(), + ValidUntil: time.Now().Add(24 * time.Hour), + Status: "SUBSCRIBED", + } + + result, err := rClient.Lookup(ctx, subscription) + require.Error(t, err) + require.Empty(t, result) + }) + } +} diff --git a/core/module/handler/config.go b/core/module/handler/config.go new file mode 100644 index 0000000..16a2c0c --- /dev/null +++ b/core/module/handler/config.go @@ -0,0 +1,55 @@ +package handler + +import ( + "context" + "net/http" + + "github.com/beckn/beckn-onix/pkg/model" + "github.com/beckn/beckn-onix/pkg/plugin" + "github.com/beckn/beckn-onix/pkg/plugin/definition" +) + +// PluginManager defines an interface for managing plugins dynamically. +type PluginManager interface { + Middleware(ctx context.Context, cfg *plugin.Config) (func(http.Handler) http.Handler, error) + SignValidator(ctx context.Context, cfg *plugin.Config) (definition.SignValidator, error) + Validator(ctx context.Context, cfg *plugin.Config) (definition.SchemaValidator, error) + Router(ctx context.Context, cfg *plugin.Config) (definition.Router, error) + Publisher(ctx context.Context, cfg *plugin.Config) (definition.Publisher, error) + Signer(ctx context.Context, cfg *plugin.Config) (definition.Signer, error) + Step(ctx context.Context, cfg *plugin.Config) (definition.Step, error) + Cache(ctx context.Context, cfg *plugin.Config) (definition.Cache, error) + KeyManager(ctx context.Context, cache definition.Cache, rLookup definition.RegistryLookup, cfg *plugin.Config) (definition.KeyManager, error) + SchemaValidator(ctx context.Context, cfg *plugin.Config) (definition.SchemaValidator, error) +} + +// Type defines different handler types for processing requests. +type Type string + +const ( + // HandlerTypeStd represents the standard handler type used for general request processing. + HandlerTypeStd Type = "std" +) + +// PluginCfg holds the configuration for various plugins. +type PluginCfg struct { + SchemaValidator *plugin.Config `yaml:"schemaValidator,omitempty"` + SignValidator *plugin.Config `yaml:"signValidator,omitempty"` + Publisher *plugin.Config `yaml:"publisher,omitempty"` + Signer *plugin.Config `yaml:"signer,omitempty"` + Router *plugin.Config `yaml:"router,omitempty"` + Cache *plugin.Config `yaml:"cache,omitempty"` + KeyManager *plugin.Config `yaml:"keyManager,omitempty"` + Middleware []plugin.Config `yaml:"middleware,omitempty"` + Steps []plugin.Config +} + +// Config holds the configuration for request processing handlers. +type Config struct { + Plugins PluginCfg `yaml:"plugins"` + Steps []string + Type Type + RegistryURL string `yaml:"registryUrl"` + Role model.Role + SubscriberID string `yaml:"subscriberId"` +} diff --git a/core/module/handler/stdHandler.go b/core/module/handler/stdHandler.go new file mode 100644 index 0000000..3a3caaa --- /dev/null +++ b/core/module/handler/stdHandler.go @@ -0,0 +1,264 @@ +package handler + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/http/httputil" + "net/url" + + "github.com/beckn/beckn-onix/core/module/client" + "github.com/beckn/beckn-onix/pkg/log" + "github.com/beckn/beckn-onix/pkg/model" + "github.com/beckn/beckn-onix/pkg/plugin" + "github.com/beckn/beckn-onix/pkg/plugin/definition" + "github.com/beckn/beckn-onix/pkg/response" +) + +// stdHandler orchestrates the execution of defined processing steps. +type stdHandler struct { + signer definition.Signer + steps []definition.Step + signValidator definition.SignValidator + cache definition.Cache + km definition.KeyManager + schemaValidator definition.SchemaValidator + router definition.Router + publisher definition.Publisher + SubscriberID string + role model.Role +} + +// NewStdHandler initializes a new processor with plugins and steps. +func NewStdHandler(ctx context.Context, mgr PluginManager, cfg *Config) (http.Handler, error) { + h := &stdHandler{ + steps: []definition.Step{}, + SubscriberID: cfg.SubscriberID, + role: cfg.Role, + } + // Initialize plugins. + if err := h.initPlugins(ctx, mgr, &cfg.Plugins, cfg.RegistryURL); err != nil { + return nil, fmt.Errorf("failed to initialize plugins: %w", err) + } + // Initialize steps. + if err := h.initSteps(ctx, mgr, cfg); err != nil { + return nil, fmt.Errorf("failed to initialize steps: %w", err) + } + return h, nil +} + +// ServeHTTP processes an incoming HTTP request and executes defined processing steps. +func (h *stdHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx, err := h.stepCtx(r, w.Header()) + if err != nil { + log.Errorf(r.Context(), err, "stepCtx(r):%v", err) + response.SendNack(r.Context(), w, err) + return + } + log.Request(r.Context(), r, ctx.Body) + + // Execute processing steps. + for _, step := range h.steps { + if err := step.Run(ctx); err != nil { + log.Errorf(ctx, err, "%T.run(%v):%v", step, ctx, err) + response.SendNack(ctx, w, err) + return + } + } + // Restore request body before forwarding or publishing. + r.Body = io.NopCloser(bytes.NewReader(ctx.Body)) + if ctx.Route == nil { + response.SendAck(w) + return + } + + // Handle routing based on the defined route type. + route(ctx, r, w, h.publisher) +} + +// stepCtx creates a new StepContext for processing an HTTP request. +func (h *stdHandler) stepCtx(r *http.Request, rh http.Header) (*model.StepContext, error) { + var bodyBuffer bytes.Buffer + if _, err := io.Copy(&bodyBuffer, r.Body); err != nil { + return nil, model.NewBadReqErr(err) + } + r.Body.Close() + subID := h.subID(r.Context()) + if len(subID) == 0 { + return nil, model.NewBadReqErr(fmt.Errorf("subscriberID not set")) + } + return &model.StepContext{ + Context: r.Context(), + Request: r, + Body: bodyBuffer.Bytes(), + Role: h.role, + SubID: subID, + RespHeader: rh, + }, nil +} + +// subID retrieves the subscriber ID from the request context. +func (h *stdHandler) subID(ctx context.Context) string { + rSubID, ok := ctx.Value(model.ContextKeySubscriberID).(string) + if ok { + return rSubID + } + return h.SubscriberID +} + +var proxyFunc = proxy + +// route handles request forwarding or message publishing based on the routing type. +func route(ctx *model.StepContext, r *http.Request, w http.ResponseWriter, pb definition.Publisher) { + log.Debugf(ctx, "Routing to ctx.Route to %#v", ctx.Route) + switch ctx.Route.TargetType { + case "url": + log.Infof(ctx.Context, "Forwarding request to URL: %s", ctx.Route.URL) + proxyFunc(r, w, ctx.Route.URL) + return + case "publisher": + if pb == nil { + err := fmt.Errorf("publisher plugin not configured") + log.Errorf(ctx.Context, err, "Invalid configuration:%v", err) + response.SendNack(ctx, w, err) + return + } + log.Infof(ctx.Context, "Publishing message to: %s", ctx.Route.PublisherID) + if err := pb.Publish(ctx, ctx.Route.PublisherID, ctx.Body); err != nil { + log.Errorf(ctx.Context, err, "Failed to publish message") + http.Error(w, "Error publishing message", http.StatusInternalServerError) + response.SendNack(ctx, w, err) + return + } + default: + err := fmt.Errorf("unknown route type: %s", ctx.Route.TargetType) + log.Errorf(ctx.Context, err, "Invalid configuration:%v", err) + response.SendNack(ctx, w, err) + return + } + response.SendAck(w) +} + +// proxy forwards the request to a target URL using a reverse proxy. +func proxy(r *http.Request, w http.ResponseWriter, target *url.URL) { + r.URL.Scheme = target.Scheme + r.URL.Host = target.Host + r.URL.Path = target.Path + + r.Header.Set("X-Forwarded-Host", r.Host) + proxy := httputil.NewSingleHostReverseProxy(target) + log.Infof(r.Context(), "Proxying request to: %s", target) + + proxy.ServeHTTP(w, r) +} + +// loadPlugin is a generic function to load and validate plugins. +func loadPlugin[T any](ctx context.Context, name string, cfg *plugin.Config, mgrFunc func(context.Context, *plugin.Config) (T, error)) (T, error) { + var zero T + if cfg == nil { + log.Debugf(ctx, "Skipping %s plugin: not configured", name) + return zero, nil + } + + plugin, err := mgrFunc(ctx, cfg) + if err != nil { + return zero, fmt.Errorf("failed to load %s plugin (%s): %w", name, cfg.ID, err) + } + + log.Debugf(ctx, "Loaded %s plugin: %s", name, cfg.ID) + return plugin, nil +} + +// loadKeyManager loads the KeyManager plugin using the provided PluginManager, cache, and registry URL. +func loadKeyManager(ctx context.Context, mgr PluginManager, cache definition.Cache, cfg *plugin.Config, regURL string) (definition.KeyManager, error) { + if cfg == nil { + log.Debug(ctx, "Skipping KeyManager plugin: not configured") + return nil, nil + } + if cache == nil { + return nil, fmt.Errorf("failed to load KeyManager plugin (%s): Cache plugin not configured", cfg.ID) + } + rClient := client.NewRegisteryClient(&client.Config{RegisteryURL: regURL}) + km, err := mgr.KeyManager(ctx, cache, rClient, cfg) + if err != nil { + return nil, fmt.Errorf("failed to load cache plugin (%s): %w", cfg.ID, err) + } + + log.Debugf(ctx, "Loaded Keymanager plugin: %s", cfg.ID) + return km, nil +} + +// initPlugins initializes required plugins for the processor. +func (h *stdHandler) initPlugins(ctx context.Context, mgr PluginManager, cfg *PluginCfg, regURL string) error { + var err error + if h.cache, err = loadPlugin(ctx, "Cache", cfg.Cache, mgr.Cache); err != nil { + return err + } + if h.km, err = loadKeyManager(ctx, mgr, h.cache, cfg.KeyManager, regURL); err != nil { + return err + } + if h.signValidator, err = loadPlugin(ctx, "SignValidator", cfg.SignValidator, mgr.SignValidator); err != nil { + return err + } + if h.schemaValidator, err = loadPlugin(ctx, "SchemaValidator", cfg.SchemaValidator, mgr.SchemaValidator); err != nil { + return err + } + if h.router, err = loadPlugin(ctx, "Router", cfg.Router, mgr.Router); err != nil { + return err + } + if h.publisher, err = loadPlugin(ctx, "Publisher", cfg.Publisher, mgr.Publisher); err != nil { + return err + } + if h.signer, err = loadPlugin(ctx, "Signer", cfg.Signer, mgr.Signer); err != nil { + return err + } + + log.Debugf(ctx, "All required plugins successfully loaded for stdHandler") + return nil +} + +// initSteps initializes and validates processing steps for the processor. +func (h *stdHandler) initSteps(ctx context.Context, mgr PluginManager, cfg *Config) error { + steps := make(map[string]definition.Step) + + // Load plugin-based steps + for _, c := range cfg.Plugins.Steps { + step, err := mgr.Step(ctx, &c) + if err != nil { + return fmt.Errorf("failed to initialize plugin step %s: %w", c.ID, err) + } + steps[c.ID] = step + } + + // Register processing steps + for _, step := range cfg.Steps { + var s definition.Step + var err error + + switch step { + case "sign": + s, err = newSignStep(h.signer, h.km) + case "validateSign": + s, err = newValidateSignStep(h.signValidator, h.km) + case "validateSchema": + s, err = newValidateSchemaStep(h.schemaValidator) + case "addRoute": + s, err = newAddRouteStep(h.router) + default: + if customStep, exists := steps[step]; exists { + s = customStep + } else { + return fmt.Errorf("unrecognized step: %s", step) + } + } + + if err != nil { + return err + } + h.steps = append(h.steps, s) + } + log.Infof(ctx, "Processor steps initialized: %v", cfg.Steps) + return nil +} diff --git a/core/module/handler/step.go b/core/module/handler/step.go new file mode 100644 index 0000000..936ee98 --- /dev/null +++ b/core/module/handler/step.go @@ -0,0 +1,169 @@ +package handler + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/beckn/beckn-onix/pkg/log" + "github.com/beckn/beckn-onix/pkg/model" + "github.com/beckn/beckn-onix/pkg/plugin/definition" +) + +// signStep represents the signing step in the processing pipeline. +type signStep struct { + signer definition.Signer + km definition.KeyManager +} + +// newSignStep initializes and returns a new signing step. +func newSignStep(signer definition.Signer, km definition.KeyManager) (definition.Step, error) { + if signer == nil { + return nil, fmt.Errorf("invalid config: Signer plugin not configured") + } + if km == nil { + return nil, fmt.Errorf("invalid config: KeyManager plugin not configured") + } + + return &signStep{signer: signer, km: km}, nil +} + +// Run executes the signing step. +func (s *signStep) Run(ctx *model.StepContext) error { + keyID, key, err := s.km.SigningPrivateKey(ctx, ctx.SubID) + if err != nil { + return fmt.Errorf("failed to get signing key: %w", err) + } + createdAt := time.Now().Unix() + validTill := time.Now().Add(5 * time.Minute).Unix() + sign, err := s.signer.Sign(ctx, ctx.Body, key, createdAt, validTill) + if err != nil { + return fmt.Errorf("failed to sign request: %w", err) + } + + authHeader := s.generateAuthHeader(ctx.SubID, keyID, createdAt, validTill, sign) + + header := model.AuthHeaderSubscriber + if ctx.Role == model.RoleGateway { + header = model.AuthHeaderGateway + } + ctx.Request.Header.Set(header, authHeader) + return nil +} + +// generateAuthHeader constructs the authorization header for the signed request. +// It includes key ID, algorithm, creation time, expiration time, required headers, and signature. +func (s *signStep) generateAuthHeader(subID, keyID string, createdAt, validTill int64, signature string) string { + return fmt.Sprintf( + "Signature keyId=\"%s|%s|ed25519\",algorithm=\"ed25519\",created=\"%d\",expires=\"%d\",headers=\"(created) (expires) digest\",signature=\"%s\"", + subID, keyID, createdAt, validTill, signature, + ) +} + +// validateSignStep represents the signature validation step. +type validateSignStep struct { + validator definition.SignValidator + km definition.KeyManager +} + +// newValidateSignStep initializes and returns a new validate sign step. +func newValidateSignStep(signValidator definition.SignValidator, km definition.KeyManager) (definition.Step, error) { + if signValidator == nil { + return nil, fmt.Errorf("invalid config: SignValidator plugin not configured") + } + if km == nil { + return nil, fmt.Errorf("invalid config: KeyManager plugin not configured") + } + return &validateSignStep{validator: signValidator, km: km}, nil +} + +// Run executes the validation step. +func (s *validateSignStep) Run(ctx *model.StepContext) error { + unauthHeader := fmt.Sprintf("Signature realm=\"%s\",headers=\"(created) (expires) digest\"", ctx.SubID) + headerValue := ctx.Request.Header.Get(model.AuthHeaderGateway) + if len(headerValue) != 0 { + if err := s.validate(ctx, headerValue); err != nil { + ctx.RespHeader.Set(model.UnaAuthorizedHeaderGateway, unauthHeader) + return model.NewSignValidationErr(fmt.Errorf("failed to validate %s: %w", model.AuthHeaderGateway, err)) + } + } + headerValue = ctx.Request.Header.Get(model.AuthHeaderSubscriber) + if len(headerValue) == 0 { + ctx.RespHeader.Set(model.UnaAuthorizedHeaderSubscriber, unauthHeader) + return model.NewSignValidationErr(fmt.Errorf("%s missing", model.UnaAuthorizedHeaderSubscriber)) + } + if err := s.validate(ctx, headerValue); err != nil { + ctx.RespHeader.Set(model.UnaAuthorizedHeaderSubscriber, unauthHeader) + return model.NewSignValidationErr(fmt.Errorf("failed to validate %s: %w", model.AuthHeaderSubscriber, err)) + } + return nil +} + +// validate checks the validity of the provided signature header. +func (s *validateSignStep) validate(ctx *model.StepContext, value string) error { + headerParts := strings.Split(value, "|") + ids := strings.Split(headerParts[0], "\"") + if len(ids) < 2 || len(headerParts) < 3 { + return fmt.Errorf("malformed sign header") + } + subID := ids[1] + keyID := headerParts[1] + key, err := s.km.SigningPublicKey(ctx, subID, keyID) + if err != nil { + return fmt.Errorf("failed to get validation key: %w", err) + } + if err := s.validator.Validate(ctx, ctx.Body, value, key); err != nil { + return fmt.Errorf("sign validation failed: %w", err) + } + return nil +} + +// validateSchemaStep represents the schema validation step. +type validateSchemaStep struct { + validator definition.SchemaValidator +} + +// newValidateSchemaStep creates and returns the validateSchema step after validation. +func newValidateSchemaStep(schemaValidator definition.SchemaValidator) (definition.Step, error) { + if schemaValidator == nil { + return nil, fmt.Errorf("invalid config: SchemaValidator plugin not configured") + } + log.Debug(context.Background(), "adding schema validator") + return &validateSchemaStep{validator: schemaValidator}, nil +} + +// Run executes the schema validation step. +func (s *validateSchemaStep) Run(ctx *model.StepContext) error { + if err := s.validator.Validate(ctx, ctx.Request.URL, ctx.Body); err != nil { + return fmt.Errorf("schema validation failed: %w", err) + } + return nil +} + +// addRouteStep represents the route determination step. +type addRouteStep struct { + router definition.Router +} + +// newAddRouteStep creates and returns the addRoute step after validation. +func newAddRouteStep(router definition.Router) (definition.Step, error) { + if router == nil { + return nil, fmt.Errorf("invalid config: Router plugin not configured") + } + return &addRouteStep{router: router}, nil +} + +// Run executes the routing step. +func (s *addRouteStep) Run(ctx *model.StepContext) error { + route, err := s.router.Route(ctx, ctx.Request.URL, ctx.Body) + if err != nil { + return fmt.Errorf("failed to determine route: %w", err) + } + ctx.Route = &model.Route{ + TargetType: route.TargetType, + PublisherID: route.PublisherID, + URL: route.URL, + } + return nil +} diff --git a/core/module/module.go b/core/module/module.go new file mode 100644 index 0000000..2a28e62 --- /dev/null +++ b/core/module/module.go @@ -0,0 +1,82 @@ +package module + +import ( + "context" + "fmt" + "net/http" + + "github.com/beckn/beckn-onix/core/module/handler" + "github.com/beckn/beckn-onix/pkg/log" + "github.com/beckn/beckn-onix/pkg/model" +) + +// Config represents the configuration for a module. +type Config struct { + Name string `yaml:"name"` + Path string `yaml:"path"` + Handler handler.Config +} + +// Provider represents a function that initializes an HTTP handler using a PluginManager. +type Provider func(ctx context.Context, mgr handler.PluginManager, cfg *handler.Config) (http.Handler, error) + +// handlerProviders maintains a mapping of handler types to their respective providers. +var handlerProviders = map[handler.Type]Provider{ + handler.HandlerTypeStd: handler.NewStdHandler, +} + +// Register initializes and registers handlers based on the provided configuration. +// It iterates over the module configurations, retrieves appropriate handler providers, +// and registers the handlers with the HTTP multiplexer. +func Register(ctx context.Context, mCfgs []Config, mux *http.ServeMux, mgr handler.PluginManager) error { + log.Debugf(ctx, "Registering modules with config: %#v", mCfgs) + // Iterate over the handlers in the configuration. + for _, c := range mCfgs { + rmp, ok := handlerProviders[c.Handler.Type] + if !ok { + return fmt.Errorf("invalid module : %s", c.Name) + } + h, err := rmp(ctx, mgr, &c.Handler) + if err != nil { + return fmt.Errorf("%s : %w", c.Name, err) + } + h, err = addMiddleware(ctx, mgr, h, &c.Handler) + if err != nil { + return fmt.Errorf("failed to add middleware: %w", err) + + } + h = moduleCtxMiddleware(c.Name, h) + log.Debugf(ctx, "Registering handler %s, of type %s @ %s", c.Name, c.Handler.Type, c.Path) + mux.Handle(c.Path, h) + } + return nil +} + +// addMiddleware applies middleware plugins to the provided handler in reverse order. +// It retrieves middleware instances from the plugin manager and chains them to the handler. +func addMiddleware(ctx context.Context, mgr handler.PluginManager, handler http.Handler, hCfg *handler.Config) (http.Handler, error) { + mws := hCfg.Plugins.Middleware + log.Debugf(ctx, "Applying %d middleware(s) to the handler", len(mws)) + // Apply the middleware in reverse order. + for i := len(mws) - 1; i >= 0; i-- { + log.Debugf(ctx, "Loading middleware: %s", mws[i].ID) + mw, err := mgr.Middleware(ctx, &mws[i]) + if err != nil { + log.Errorf(ctx, err, "Failed to load middleware %s: %v", mws[i].ID, err) + return nil, fmt.Errorf("failed to load middleware %s: %w", mws[i].ID, err) + } + // Apply the middleware to the handler. + handler = mw(handler) + log.Debugf(ctx, "Applied middleware: %s", mws[i].ID) + } + + log.Debugf(ctx, "Middleware chain setup completed") + return handler, nil +} + +func moduleCtxMiddleware(moduleName string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), model.ContextKeyModuleID, moduleName) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/core/module/module_test.go b/core/module/module_test.go new file mode 100644 index 0000000..ffeaafe --- /dev/null +++ b/core/module/module_test.go @@ -0,0 +1,175 @@ +package module + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/beckn/beckn-onix/core/module/handler" + "github.com/beckn/beckn-onix/pkg/model" + "github.com/beckn/beckn-onix/pkg/plugin" + "github.com/beckn/beckn-onix/pkg/plugin/definition" +) + +// mockPluginManager is a mock implementation of the PluginManager interface +// with support for dynamically setting behavior. +type mockPluginManager struct { + middlewareFunc func(ctx context.Context, cfg *plugin.Config) (func(http.Handler) http.Handler, error) +} + +// Middleware returns a mock middleware function based on the provided configuration. +func (m *mockPluginManager) Middleware(ctx context.Context, cfg *plugin.Config) (func(http.Handler) http.Handler, error) { + return m.middlewareFunc(ctx, cfg) +} + +// SignValidator returns a mock verifier implementation. +func (m *mockPluginManager) SignValidator(ctx context.Context, cfg *plugin.Config) (definition.SignValidator, error) { + return nil, nil +} + +// Validator returns a mock schema validator implementation. +func (m *mockPluginManager) Validator(ctx context.Context, cfg *plugin.Config) (definition.SchemaValidator, error) { + return nil, nil +} + +// Router returns a mock router implementation. +func (m *mockPluginManager) Router(ctx context.Context, cfg *plugin.Config) (definition.Router, error) { + return nil, nil +} + +// Publisher returns a mock publisher implementation. +func (m *mockPluginManager) Publisher(ctx context.Context, cfg *plugin.Config) (definition.Publisher, error) { + return nil, nil +} + +// Signer returns a mock signer implementation. +func (m *mockPluginManager) Signer(ctx context.Context, cfg *plugin.Config) (definition.Signer, error) { + return nil, nil +} + +// Step returns a mock step implementation. +func (m *mockPluginManager) Step(ctx context.Context, cfg *plugin.Config) (definition.Step, error) { + return nil, nil +} + +// Cache returns a mock cache implementation. +func (m *mockPluginManager) Cache(ctx context.Context, cfg *plugin.Config) (definition.Cache, error) { + return nil, nil +} + +// KeyManager returns a mock key manager implementation. +func (m *mockPluginManager) KeyManager(ctx context.Context, cache definition.Cache, rLookup definition.RegistryLookup, cfg *plugin.Config) (definition.KeyManager, error) { + return nil, nil +} + +// SchemaValidator returns a mock schema validator implementation. +func (m *mockPluginManager) SchemaValidator(ctx context.Context, cfg *plugin.Config) (definition.SchemaValidator, error) { + return nil, nil +} + +// TestRegisterSuccess tests scenarios where the handler registration should succeed. +func TestRegisterSuccess(t *testing.T) { + mCfgs := []Config{ + { + Name: "test-module", + Path: "/test", + Handler: handler.Config{ + Type: handler.HandlerTypeStd, + Plugins: handler.PluginCfg{ + Middleware: []plugin.Config{{ID: "mock-middleware"}}, + }, + }, + }, + } + + mockManager := &mockPluginManager{ + middlewareFunc: func(ctx context.Context, cfg *plugin.Config) (func(http.Handler) http.Handler, error) { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(w, r) + }) + }, nil + }, + } + + mux := http.NewServeMux() + err := Register(context.Background(), mCfgs, mux, mockManager) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + // Create a request and a response recorder + req := httptest.NewRequest(http.MethodGet, "/test", nil) + rec := httptest.NewRecorder() + + // Create a handler that extracts context + var capturedModuleName any + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedModuleName = r.Context().Value(model.ContextKeyModuleID) + w.WriteHeader(http.StatusOK) + }) + + wrappedHandler := moduleCtxMiddleware("test-module", testHandler) + wrappedHandler.ServeHTTP(rec, req) + + // Now verify if module name exists in context + if capturedModuleName != "test-module" { + t.Errorf("expected module_id in context to be 'test-module', got %v", capturedModuleName) + } + +} + +// TestRegisterFailure tests scenarios where the handler registration should fail. +func TestRegisterFailure(t *testing.T) { + tests := []struct { + name string + mCfgs []Config + mockManager *mockPluginManager + }{ + { + name: "invalid handler type", + mCfgs: []Config{ + { + Name: "invalid-module", + Path: "/invalid", + Handler: handler.Config{ + Type: "invalid-type", + }, + }, + }, + mockManager: &mockPluginManager{}, + }, + { + name: "middleware error", + mCfgs: []Config{ + { + Name: "test-module", + Path: "/test", + Handler: handler.Config{ + Type: handler.HandlerTypeStd, + Plugins: handler.PluginCfg{ + Middleware: []plugin.Config{{ID: "mock-middleware"}}, + }, + }, + }, + }, + mockManager: &mockPluginManager{ + middlewareFunc: func(ctx context.Context, cfg *plugin.Config) (func(http.Handler) http.Handler, error) { + return nil, errors.New("middleware error") + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mux := http.NewServeMux() + err := Register(context.Background(), tt.mCfgs, mux, tt.mockManager) + if err == nil { + t.Errorf("expected an error but got nil") + } + }) + } +} diff --git a/go.mod b/go.mod index f050463..12fad60 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,38 @@ module github.com/beckn/beckn-onix -go 1.23.4 +go 1.24 require ( + github.com/kr/pretty v0.3.1 // indirect + github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 - gopkg.in/yaml.v2 v2.4.0 + golang.org/x/crypto v0.36.0 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) -require golang.org/x/text v0.14.0 // indirect +require github.com/stretchr/testify v1.10.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + gopkg.in/yaml.v3 v3.0.1 +) + +require github.com/zenazn/pkcs7pad v0.0.0-20170308005700-253a5b1f0e03 + +require golang.org/x/text v0.23.0 // indirect + +require ( + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/sys v0.31.0 // indirect +) + +require ( + github.com/hashicorp/go-retryablehttp v0.7.7 + github.com/rs/zerolog v1.34.0 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 + gopkg.in/yaml.v2 v2.4.0 +) diff --git a/go.sum b/go.sum index 2c8aa28..821e117 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,64 @@ +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+xZwwfKHgph2lxBw= github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/zenazn/pkcs7pad v0.0.0-20170308005700-253a5b1f0e03 h1:m1h+vudopHsI67FPT9MOncyndWhTcdUoBtI1R1uajGY= +github.com/zenazn/pkcs7pad v0.0.0-20170308005700-253a5b1f0e03/go.mod h1:8sheVFH84v3PCyFY/O02mIgSQY9I6wMYPWsq7mDnEZY= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/log/log.go b/pkg/log/log.go new file mode 100644 index 0000000..1f5f59f --- /dev/null +++ b/pkg/log/log.go @@ -0,0 +1,295 @@ +package log + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "sync" + "time" + + "github.com/beckn/beckn-onix/pkg/model" + "github.com/rs/zerolog" + "gopkg.in/natefinch/lumberjack.v2" +) + +type level string + +type destinationType string + +type destination struct { + Type destinationType `yaml:"type"` + Config map[string]string `yaml:"config"` +} + +// Destination types for logging output. +const ( + Stdout destinationType = "stdout" + File destinationType = "file" +) + +// Log levels define the severity of log messages. +const ( + DebugLevel level = "debug" + InfoLevel level = "info" + WarnLevel level = "warn" + ErrorLevel level = "error" + FatalLevel level = "fatal" + PanicLevel level = "panic" +) + +var logLevels = map[level]zerolog.Level{ + DebugLevel: zerolog.DebugLevel, + InfoLevel: zerolog.InfoLevel, + WarnLevel: zerolog.WarnLevel, + ErrorLevel: zerolog.ErrorLevel, + FatalLevel: zerolog.FatalLevel, + PanicLevel: zerolog.PanicLevel, +} + +// Config represents the configuration for logging. +type Config struct { + Level level `yaml:"level"` + Destinations []destination `yaml:"destinations"` + ContextKeys []model.ContextKey `yaml:"contextKeys"` +} + +var ( + logger zerolog.Logger + cfg Config + once sync.Once +) + +// Logger instance and configuration. +var ( + ErrInvalidLogLevel = errors.New("invalid log level") + ErrLogDestinationNil = errors.New("log Destinations cant be empty") + ErrMissingFilePath = errors.New("file path missing in destination config for file logging") +) + +func (config *Config) validate() error { + if _, exists := logLevels[config.Level]; !exists { + return ErrInvalidLogLevel + } + + if len(config.Destinations) == 0 { + return ErrLogDestinationNil + } + + for _, dest := range config.Destinations { + switch dest.Type { + case Stdout: + case File: + if _, exists := dest.Config["path"]; !exists { + return ErrMissingFilePath + } + + for _, key := range []string{"maxSize", "maxBackups", "maxAge"} { + if valStr, ok := dest.Config[key]; ok { + if _, err := strconv.Atoi(valStr); err != nil { + return fmt.Errorf("invalid %s: %w", key, err) + } + } + } + default: + return fmt.Errorf("invalid destination type '%s'", dest.Type) + } + } + return nil +} + +var defaultConfig = Config{ + Level: InfoLevel, + Destinations: []destination{ + {Type: Stdout}, + }, +} + +func init() { + logger, _ = getLogger(defaultConfig) +} + +func getLogger(config Config) (zerolog.Logger, error) { + var newLogger zerolog.Logger + var writers []io.Writer + for _, dest := range config.Destinations { + switch dest.Type { + case Stdout: + writers = append(writers, os.Stdout) + case File: + filePath := dest.Config["path"] + dir := filepath.Dir(filePath) + if err := os.MkdirAll(dir, os.ModePerm); err != nil { + return newLogger, fmt.Errorf("failed to create log directory: %v", err) + } + lumberjackLogger := &lumberjack.Logger{ + Filename: filePath, + Compress: false, + } + absPath, err := filepath.Abs(filePath) + if err != nil { + return newLogger, fmt.Errorf("failed to get absolute path: %v", err) + } + lumberjackLogger.Filename = absPath + + setConfigValue := func(key string, target *int) { + if valStr, ok := dest.Config[key]; ok { + if val, err := strconv.Atoi(valStr); err == nil { + *target = val + } + } + } + setConfigValue("maxSize", &lumberjackLogger.MaxSize) + setConfigValue("maxBackups", &lumberjackLogger.MaxBackups) + setConfigValue("maxAge", &lumberjackLogger.MaxAge) + if compress, ok := dest.Config["compress"]; ok { + lumberjackLogger.Compress = compress == "true" + } + writers = append(writers, lumberjackLogger) + } + } + multiwriter := io.MultiWriter(writers...) + defer func() { + if closer, ok := multiwriter.(io.Closer); ok { + closer.Close() + } + }() + newLogger = zerolog.New(multiwriter). + Level(logLevels[config.Level]). + With(). + Timestamp(). + Logger() + + cfg = config + return newLogger, nil +} + +// InitLogger initializes the logger with the given configuration. +// It ensures that the logger is initialized only once using sync.Once. +func InitLogger(c Config) error { + var initErr error + once.Do(func() { + if initErr = c.validate(); initErr != nil { + return + } + + logger, initErr = getLogger(c) + }) + return initErr +} + +// Debug logs a debug-level message with the provided context. +func Debug(ctx context.Context, msg string) { + logEvent(ctx, zerolog.DebugLevel, msg, nil) +} + +// Debugf logs a formatted debug-level message with the provided context. +func Debugf(ctx context.Context, format string, v ...any) { + msg := fmt.Sprintf(format, v...) + logEvent(ctx, zerolog.DebugLevel, msg, nil) +} + +// Info logs an info-level message with the provided context. +func Info(ctx context.Context, msg string) { + logEvent(ctx, zerolog.InfoLevel, msg, nil) +} + +// Infof logs a formatted info-level message with the provided context. +func Infof(ctx context.Context, format string, v ...any) { + msg := fmt.Sprintf(format, v...) + logEvent(ctx, zerolog.InfoLevel, msg, nil) +} + +// Warn logs a warning-level message with the provided context. +func Warn(ctx context.Context, msg string) { + logEvent(ctx, zerolog.WarnLevel, msg, nil) +} + +// Warnf logs a formatted warning-level message with the provided context. +func Warnf(ctx context.Context, format string, v ...any) { + msg := fmt.Sprintf(format, v...) + logEvent(ctx, zerolog.WarnLevel, msg, nil) +} + +// Error logs an error-level message along with an error object. +func Error(ctx context.Context, err error, msg string) { + logEvent(ctx, zerolog.ErrorLevel, msg, err) +} + +// Errorf logs a formatted error-level message along with an error object. +func Errorf(ctx context.Context, err error, format string, v ...any) { + msg := fmt.Sprintf(format, v...) + logEvent(ctx, zerolog.ErrorLevel, msg, err) +} + +// Fatal logs a fatal-level message along with an error object and exits the application. +func Fatal(ctx context.Context, err error, msg string) { + logEvent(ctx, zerolog.FatalLevel, msg, err) +} + +// Fatalf logs a formatted fatal-level message along with an error object and exits the application. +func Fatalf(ctx context.Context, err error, format string, v ...any) { + msg := fmt.Sprintf(format, v...) + logEvent(ctx, zerolog.FatalLevel, msg, err) +} + +// Panic logs a panic-level message along with an error object and panics. +func Panic(ctx context.Context, err error, msg string) { + logEvent(ctx, zerolog.PanicLevel, msg, err) +} + +// Panicf logs a formatted panic-level message along with an error object and panics. +func Panicf(ctx context.Context, err error, format string, v ...any) { + msg := fmt.Sprintf(format, v...) + logEvent(ctx, zerolog.PanicLevel, msg, err) +} + +// logEvent logs an event at the specified log level with an optional error message. +// It adds contextual information before logging the message. +func logEvent(ctx context.Context, level zerolog.Level, msg string, err error) { + event := logger.WithLevel(level) + + if err != nil { + event = event.Err(err) + } + addCtx(ctx, event) + event.Msg(msg) +} + +// Request logs details of an incoming HTTP request, including method, URL, body, and remote address. +func Request(ctx context.Context, r *http.Request, body []byte) { + event := logger.Info() + addCtx(ctx, event) + event.Str("method", r.Method). + Str("url", r.URL.String()). + Str("body", string(body)). + Str("remoteAddr", r.RemoteAddr). + Msg("HTTP Request") +} + +// addCtx adds context values to the log event based on configured context keys. +func addCtx(ctx context.Context, event *zerolog.Event) { + for _, key := range cfg.ContextKeys { + val, ok := ctx.Value(key).(string) + if !ok { + continue + } + keyStr := string(key) + event.Any(keyStr, val) + } +} + +// Response logs details of an outgoing HTTP response, including method, URL, status code, and response time. +func Response(ctx context.Context, r *http.Request, statusCode int, responseTime time.Duration) { + event := logger.Info() + addCtx(ctx, event) + event.Str("method", r.Method). + Str("url", r.URL.String()). + Int("statusCode", statusCode). + Dur("responseTime", responseTime). + Msg("HTTP Response") +} diff --git a/pkg/log/log_test.go b/pkg/log/log_test.go new file mode 100644 index 0000000..2b93d73 --- /dev/null +++ b/pkg/log/log_test.go @@ -0,0 +1,673 @@ +package log + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/beckn/beckn-onix/pkg/model" +) + +type ctxKey any + +var requestID ctxKey = "requestID" + +const testLogFilePath = "./test_logs/test.log" + +func TestMain(m *testing.M) { + // Create a single temporary directory for all tests + var err error + dir := filepath.Dir(testLogFilePath) + err = os.MkdirAll(dir, os.ModePerm) + if err != nil { + panic("failed to create test log directory: " + err.Error()) + } + + // Run all tests + code := m.Run() + + // Cleanup: Remove the log directory after all tests finish + err = os.RemoveAll(dir) + if err != nil { + println("failed to clean up test log directory: ", err.Error()) + } + + // Exit with the appropriate exit code + os.Exit(code) +} + +func setupLogger(t *testing.T, l level) string { + t.Helper() + + // Create a temporary directory for logs. + + config := Config{ + Level: l, + Destinations: []destination{ + { + Type: File, + Config: map[string]string{ + "path": testLogFilePath, + "maxSize": "1", + "maxAge": "1", + "maxBackup": "1", + "compress": "false", + }, + }, + }, + ContextKeys: []model.ContextKey{ + model.ContextKeyTxnID, + model.ContextKeyMsgID, + model.ContextKeySubscriberID, + model.ContextKeyModuleID, + }, + } + + // Initialize logger with the given config + err := InitLogger(config) + if err != nil { + t.Fatalf("failed to initialize logger: %v", err) + } + + return testLogFilePath +} + +func readLogFile(t *testing.T, logPath string) []string { + t.Helper() + b, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("failed to read log file: %v", err) + } + return strings.Split(string(b), "\n") +} + +func parseLogLine(t *testing.T, line string) map[string]interface{} { + t.Helper() + var logEntry map[string]interface{} + err := json.Unmarshal([]byte(line), &logEntry) + if err != nil { + t.Fatalf("Failed to parse log line: %v", err) + } + return logEntry +} + +func TestDebug(t *testing.T) { + t.Helper() + logPath := setupLogger(t, DebugLevel) + ctx := context.WithValue(context.Background(), model.ContextKeySubscriberID, "12345") + Debug(ctx, "Debug message") + lines := readLogFile(t, logPath) + if len(lines) == 0 { + t.Fatal("No logs were written.") + } + expected := map[string]interface{}{ + "level": "debug", + "subscriber_id": "12345", + "message": "Debug message", + } + + var found bool + for _, line := range lines { + logEntry := parseLogLine(t, line) + + // Ignore 'time' while comparing + delete(logEntry, "time") + + if reflect.DeepEqual(expected, logEntry) { + found = true + break + } + } + + if !found { + t.Errorf("Expected Debug message, but it was not found in logs") + } +} + +func TestInfo(t *testing.T) { + logPath := setupLogger(t, InfoLevel) + ctx := context.WithValue(context.Background(), model.ContextKeySubscriberID, "12345") + Info(ctx, "Info message") + lines := readLogFile(t, logPath) + if len(lines) == 0 { + t.Fatal("No logs were written.") + } + expected := map[string]interface{}{ + "level": "info", + "subscriber_id": "12345", + "message": "Info message", + } + + var found bool + for _, line := range lines { + logEntry := parseLogLine(t, line) + + // Ignore 'time' while comparing + delete(logEntry, "time") + + if reflect.DeepEqual(expected, logEntry) { + found = true + break + } + } + + if !found { + t.Errorf("expected Info message, but it was not found in logs") + } +} + +func TestWarn(t *testing.T) { + logPath := setupLogger(t, WarnLevel) + ctx := context.WithValue(context.Background(), model.ContextKeySubscriberID, "12345") + Warn(ctx, "Warning message") + lines := readLogFile(t, logPath) + if len(lines) == 0 { + t.Fatal("No logs were written.") + } + expected := map[string]interface{}{ + "level": "warn", + "subscriber_id": "12345", + "message": "Warning message", + } + + var found bool + for _, line := range lines { + logEntry := parseLogLine(t, line) + delete(logEntry, "time") + if reflect.DeepEqual(expected, logEntry) { + found = true + break + } + } + if !found { + t.Errorf("expected Warning message, but it was not found in logs") + } +} + +func TestError(t *testing.T) { + logPath := setupLogger(t, ErrorLevel) + ctx := context.WithValue(context.Background(), model.ContextKeySubscriberID, "12345") + Error(ctx, fmt.Errorf("test error"), "Error message") + lines := readLogFile(t, logPath) + if len(lines) == 0 { + t.Fatal("No logs were written.") + } + expected := map[string]interface{}{ + "level": "error", + "subscriber_id": "12345", + "message": "Error message", + "error": "test error", + } + + var found bool + for _, line := range lines { + logEntry := parseLogLine(t, line) + // Ignore 'time' while comparing + delete(logEntry, "time") + + if reflect.DeepEqual(expected, logEntry) { + found = true + break + } + } + if !found { + t.Errorf("expected Error message, but it was not found in logs") + } +} + +func TestRequest(t *testing.T) { + logPath := setupLogger(t, InfoLevel) + ctx := context.WithValue(context.Background(), requestID, "abc-123") + req, _ := http.NewRequest("POST", "/api/test", bytes.NewBuffer([]byte(`{"key":"value"}`))) + req.RemoteAddr = "127.0.0.1:8080" + Request(ctx, req, []byte(`{"key":"value"}`)) + lines := readLogFile(t, logPath) + if len(lines) == 0 { + t.Fatal("No logs were written.") + } + var found bool + for _, line := range lines { + logEntry := parseLogLine(t, line) + if logEntry["message"] == "HTTP Request" || logEntry["method"] == "POST" { + found = true + break + } + } + if !found { + t.Errorf("expected formatted debug message, but it was not found in logs") + } +} + +func TestResponse(t *testing.T) { + logPath := setupLogger(t, InfoLevel) + ctx := context.WithValue(context.Background(), requestID, "abc-123") + req, _ := http.NewRequest("GET", "/api/test", nil) + Response(ctx, req, 200, time.Millisecond*123) + lines := readLogFile(t, logPath) + if len(lines) == 0 { + t.Fatal("No logs were written.") + } + var found bool + for _, line := range lines { + logEntry := parseLogLine(t, line) + if logEntry["message"] == "HTTP Response" { + if logEntry["message"] == "HTTP Response" { + value, ok := logEntry["statusCode"] + if !ok { + t.Fatalf("Expected key 'statusCode' not found in log entry") + } + statusCode, ok := value.(float64) + if !ok { + t.Fatalf("Value for 'statusCode' is not a float64, found: %T", value) + } + if statusCode == 200 { + found = true + break + } + } + } + } + if !found { + t.Errorf("expected message, but it was not found in logs") + } +} + +func TestFatal(t *testing.T) { + logPath := setupLogger(t, FatalLevel) + ctx := context.WithValue(context.Background(), model.ContextKeySubscriberID, "12345") + Fatal(ctx, fmt.Errorf("fatal error"), "Fatal message") + lines := readLogFile(t, logPath) + if len(lines) == 0 { + t.Fatal("No logs were written.") + } + expected := map[string]interface{}{ + "level": "fatal", + "subscriber_id": "12345", + "message": "Fatal message", + "error": "fatal error", + } + + var found bool + for _, line := range lines { + logEntry := parseLogLine(t, line) + // Ignore 'time' while comparing + delete(logEntry, "time") + + if reflect.DeepEqual(expected, logEntry) { + found = true + break + } + } + if !found { + t.Errorf("expected Fatal message, but it was not found in logs") + } +} + +func TestPanic(t *testing.T) { + logPath := setupLogger(t, PanicLevel) + ctx := context.WithValue(context.Background(), model.ContextKeySubscriberID, "12345") + Panic(ctx, fmt.Errorf("panic error"), "Panic message") + lines := readLogFile(t, logPath) + if len(lines) == 0 { + t.Fatal("No logs were written.") + } + expected := map[string]interface{}{ + "level": "panic", + "subscriber_id": "12345", + "message": "Panic message", + "error": "panic error", + } + + var found bool + for _, line := range lines { + logEntry := parseLogLine(t, line) + // Ignore 'time' while comparing + delete(logEntry, "time") + + if reflect.DeepEqual(expected, logEntry) { + found = true + break + } + } + if !found { + t.Errorf("expected Panic message, but it was not found in logs") + } +} + +func TestDebugf(t *testing.T) { + logPath := setupLogger(t, DebugLevel) + ctx := context.WithValue(context.Background(), model.ContextKeySubscriberID, "12345") + Debugf(ctx, "Debugf message: %s", "test") + lines := readLogFile(t, logPath) + if len(lines) == 0 { + t.Fatal("No logs were written.") + } + expected := map[string]interface{}{ + "level": "debug", + "subscriber_id": "12345", + "message": "Debugf message: test", + } + + var found bool + for _, line := range lines { + logEntry := parseLogLine(t, line) + t.Log(line) + // Ignore 'time' while comparing + delete(logEntry, "time") + + if reflect.DeepEqual(expected, logEntry) { + found = true + break + } + } + if !found { + t.Errorf("expected formatted debug message, but it was not found in logs") + } +} + +func TestInfof(t *testing.T) { + logPath := setupLogger(t, InfoLevel) + ctx := context.WithValue(context.Background(), model.ContextKeySubscriberID, "12345") + Infof(ctx, "Infof message: %s", "test") + lines := readLogFile(t, logPath) + if len(lines) == 0 { + t.Fatal("No logs were written.") + } + expected := map[string]interface{}{ + "level": "info", + "subscriber_id": "12345", + "message": "Infof message: test", + } + + var found bool + for _, line := range lines { + logEntry := parseLogLine(t, line) + // Ignore 'time' while comparing + delete(logEntry, "time") + + if reflect.DeepEqual(expected, logEntry) { + found = true + break + } + } + if !found { + t.Errorf("expected Infof message, but it was not found in logs") + } +} + +func TestWarnf(t *testing.T) { + logPath := setupLogger(t, WarnLevel) + ctx := context.WithValue(context.Background(), model.ContextKeySubscriberID, "12345") + Warnf(ctx, "Warnf message: %s", "test") + lines := readLogFile(t, logPath) + if len(lines) == 0 { + t.Fatal("No logs were written.") + } + expected := map[string]interface{}{ + "level": "warn", + "subscriber_id": "12345", + "message": "Warnf message: test", + } + + var found bool + for _, line := range lines { + logEntry := parseLogLine(t, line) + // Ignore 'time' while comparing + delete(logEntry, "time") + + if reflect.DeepEqual(expected, logEntry) { + found = true + break + } + } + if !found { + t.Errorf("expected Warnf message, but it was not found in logs") + } +} + +func TestErrorf(t *testing.T) { + logPath := setupLogger(t, ErrorLevel) + ctx := context.WithValue(context.Background(), model.ContextKeySubscriberID, "12345") + err := fmt.Errorf("error message") + Errorf(ctx, err, "Errorf message: %s", "test") + lines := readLogFile(t, logPath) + if len(lines) == 0 { + t.Fatal("No logs were written.") + } + expected := map[string]interface{}{ + "level": "error", + "subscriber_id": "12345", + "message": "Errorf message: test", + "error": "error message", + } + + var found bool + for _, line := range lines { + logEntry := parseLogLine(t, line) + // Ignore 'time' while comparing + delete(logEntry, "time") + + if reflect.DeepEqual(expected, logEntry) { + found = true + break + } + } + if !found { + t.Errorf("expected Errorf message, but it was not found in logs") + } +} + +func TestFatalf(t *testing.T) { + logPath := setupLogger(t, FatalLevel) + ctx := context.WithValue(context.Background(), model.ContextKeySubscriberID, "12345") + err := fmt.Errorf("fatal error") + Fatalf(ctx, err, "Fatalf message: %s", "test") + lines := readLogFile(t, logPath) + if len(lines) == 0 { + t.Fatal("No logs were written.") + } + expected := map[string]interface{}{ + "level": "fatal", + "subscriber_id": "12345", + "message": "Fatalf message: test", + "error": "fatal error", + } + + var found bool + for _, line := range lines { + logEntry := parseLogLine(t, line) + // Ignore 'time' while comparing + delete(logEntry, "time") + + if reflect.DeepEqual(expected, logEntry) { + found = true + break + } + } + if !found { + t.Errorf("expected Fatalf message, but it was not found in logs") + } +} + +func TestPanicf(t *testing.T) { + logPath := setupLogger(t, PanicLevel) + ctx := context.WithValue(context.Background(), model.ContextKeySubscriberID, "12345") + err := fmt.Errorf("panic error") + Panicf(ctx, err, "Panicf message: %s", "test") + lines := readLogFile(t, logPath) + + if len(lines) == 0 { + t.Fatal("No logs were written.") + } + expected := map[string]interface{}{ + "level": "panic", + "subscriber_id": "12345", + "message": "Panicf message: test", + "error": "panic error", + } + + var found bool + for _, line := range lines { + logEntry := parseLogLine(t, line) + // Ignore 'time' while comparing + delete(logEntry, "time") + + if reflect.DeepEqual(expected, logEntry) { + found = true + break + } + } + + if !found { + t.Errorf("expected Panicf message, but it was not found in logs") + } +} + +func TestValidateConfig(t *testing.T) { + tests := []struct { + name string + config Config + wantErr error + }{ + { + name: "Valid config with Stdout", + config: Config{ + Level: InfoLevel, + Destinations: []destination{ + {Type: Stdout}, + }, + }, + wantErr: nil, + }, + { + name: "Valid config with File destination and valid path", + config: Config{ + Level: InfoLevel, + Destinations: []destination{ + { + Type: File, + Config: map[string]string{ + "path": "./logs/app.log", + "maxSize": "10", + "maxBackups": "5", + "maxAge": "7", + }, + }, + }, + }, + wantErr: nil, + }, + { + name: "Error: Invalid log level", + config: Config{ + Level: "invalid", + Destinations: []destination{ + {Type: Stdout}, + }, + }, + wantErr: ErrInvalidLogLevel, + }, + { + name: "Error: No destinations provided", + config: Config{ + Level: InfoLevel, + Destinations: []destination{}, + }, + wantErr: ErrLogDestinationNil, + }, + { + name: "Error: Invalid destination type", + config: Config{ + Level: InfoLevel, + Destinations: []destination{ + {Type: "unknown"}, + }, + }, + wantErr: fmt.Errorf("invalid destination type 'unknown'"), + }, + { + name: "Error: Missing file path for file destination", + config: Config{ + Level: InfoLevel, + Destinations: []destination{ + { + Type: File, + Config: map[string]string{ + "maxSize": "10", + }, + }, + }, + }, + wantErr: ErrMissingFilePath, + }, + { + name: "Error: Invalid maxSize value in file destination", + config: Config{ + Level: InfoLevel, + Destinations: []destination{ + { + Type: File, + Config: map[string]string{ + "path": "./logs/app.log", + "maxSize": "invalid", + }, + }, + }, + }, + wantErr: errors.New(`invalid maxSize: strconv.Atoi: parsing "invalid": invalid syntax`), + }, + { + name: "Error: Invalid maxBackups value in file destination", + config: Config{ + Level: InfoLevel, + Destinations: []destination{ + { + Type: File, + Config: map[string]string{ + "path": "./logs/app.log", + "maxBackups": "invalid", + }, + }, + }, + }, + wantErr: errors.New(`invalid maxBackups: strconv.Atoi: parsing "invalid": invalid syntax`), + }, + { + name: "Error: Invalid maxAge value in file destination", + config: Config{ + Level: InfoLevel, + Destinations: []destination{ + { + Type: File, + Config: map[string]string{ + "path": "./logs/app.log", + "maxAge": "invalid", + }, + }, + }, + }, + wantErr: errors.New(`invalid maxAge: strconv.Atoi: parsing "invalid": invalid syntax`), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.validate() + if (err == nil) != (tt.wantErr == nil) { + t.Errorf("validate() error = %v, wantErr %v", err, tt.wantErr) + } + if err != nil && tt.wantErr != nil && err.Error() != tt.wantErr.Error() { + t.Errorf("validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/pkg/model/error.go b/pkg/model/error.go new file mode 100644 index 0000000..5cdee1d --- /dev/null +++ b/pkg/model/error.go @@ -0,0 +1,113 @@ +package model + +import ( + "fmt" + "net/http" + "strings" +) + +// Error represents a standard error response. +type Error struct { + Code string `json:"code"` + Paths string `json:"paths,omitempty"` + Message string `json:"message"` +} + +// This implements the error interface for the Error struct. +func (e *Error) Error() string { + return fmt.Sprintf("Error: Code=%s, Path=%s, Message=%s", e.Code, e.Paths, e.Message) +} + +// SchemaValidationErr occurs when schema validation errors are encountered. +type SchemaValidationErr struct { + Errors []Error +} + +// This implements the error interface for SchemaValidationErr. +func (e *SchemaValidationErr) Error() string { + var errorMessages []string + for _, err := range e.Errors { + errorMessages = append(errorMessages, fmt.Sprintf("%s: %s", err.Paths, err.Message)) + } + return strings.Join(errorMessages, "; ") +} + +// BecknError converts the SchemaValidationErr to an instance of Error. +func (e *SchemaValidationErr) BecknError() *Error { + if len(e.Errors) == 0 { + return &Error{ + Code: http.StatusText(http.StatusBadRequest), + Message: "Schema validation error.", + } + } + + // Collect all error paths and messages + var paths []string + var messages []string + for _, err := range e.Errors { + if err.Paths != "" { + paths = append(paths, err.Paths) + } + messages = append(messages, err.Message) + } + + return &Error{ + Code: http.StatusText(http.StatusBadRequest), + Paths: strings.Join(paths, ";"), + Message: strings.Join(messages, "; "), + } +} + +// SignValidationErr occurs when signature validation fails. +type SignValidationErr struct { + error +} + +// NewSignValidationErr creates a new instance of SignValidationErr from an error. +func NewSignValidationErr(e error) *SignValidationErr { + return &SignValidationErr{e} +} + +// BecknError converts the SignValidationErr to an instance of Error. +func (e *SignValidationErr) BecknError() *Error { + return &Error{ + Code: http.StatusText(http.StatusUnauthorized), + Message: "Signature Validation Error: " + e.Error(), + } +} + +// BadReqErr occurs when a bad request is encountered. +type BadReqErr struct { + error +} + +// NewBadReqErr creates a new instance of BadReqErr from an error. +func NewBadReqErr(err error) *BadReqErr { + return &BadReqErr{err} +} + +// BecknError converts the BadReqErr to an instance of Error. +func (e *BadReqErr) BecknError() *Error { + return &Error{ + Code: http.StatusText(http.StatusBadRequest), + Message: "BAD Request: " + e.Error(), + } +} + +// NotFoundErr occurs when a requested endpoint is not found. +type NotFoundErr struct { + error +} + +// NewNotFoundErr creates a new instance of NotFoundErr from an error. +func NewNotFoundErr(err error) *NotFoundErr { + return &NotFoundErr{err} +} + +// BecknError converts the NotFoundErr to an instance of Error. +func (e *NotFoundErr) BecknError() *Error { + return &Error{ + Code: http.StatusText(http.StatusNotFound), + Message: "Endpoint not found: " + e.Error(), + } +} diff --git a/pkg/model/error_test.go b/pkg/model/error_test.go new file mode 100644 index 0000000..1ac952e --- /dev/null +++ b/pkg/model/error_test.go @@ -0,0 +1,252 @@ +package model + +import ( + "errors" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v2" +) + +// NewSignValidationErrf creates a new SignValidationErr with a formatted error message. +func NewSignValidationErrf(format string, a ...any) *SignValidationErr { + return &SignValidationErr{fmt.Errorf(format, a...)} +} + +// NewNotFoundErrf creates a new NotFoundErr with a formatted error message. +func NewNotFoundErrf(format string, a ...any) *NotFoundErr { + return &NotFoundErr{fmt.Errorf(format, a...)} +} + +// NewBadReqErrf creates a new BadReqErr with a formatted error message. +func NewBadReqErrf(format string, a ...any) *BadReqErr { + return &BadReqErr{fmt.Errorf(format, a...)} +} + +func TestError_Error(t *testing.T) { + err := &Error{ + Code: "404", + Paths: "/api/v1/user", + Message: "User not found", + } + + expected := "Error: Code=404, Path=/api/v1/user, Message=User not found" + actual := err.Error() + + if actual != expected { + t.Errorf("err.Error() = %s, want %s", + actual, expected) + } + +} + +func TestSchemaValidationErr_Error(t *testing.T) { + schemaErr := &SchemaValidationErr{ + Errors: []Error{ + {Paths: "/user", Message: "Field required"}, + {Paths: "/email", Message: "Invalid format"}, + }, + } + + expected := "/user: Field required; /email: Invalid format" + actual := schemaErr.Error() + + if actual != expected { + t.Errorf("err.Error() = %s, want %s", + actual, expected) + } +} + +func TestSchemaValidationErr_BecknError(t *testing.T) { + schemaErr := &SchemaValidationErr{ + Errors: []Error{ + {Paths: "/user", Message: "Field required"}, + }, + } + + beErr := schemaErr.BecknError() + expected := "Bad Request" + if beErr.Code != expected { + t.Errorf("err.Error() = %s, want %s", + beErr.Code, expected) + } +} + +func TestSignValidationErr_BecknError(t *testing.T) { + signErr := NewSignValidationErr(errors.New("signature failed")) + beErr := signErr.BecknError() + + expectedMsg := "Signature Validation Error: signature failed" + if beErr.Message != expectedMsg { + t.Errorf("err.Error() = %s, want %s", + beErr.Message, expectedMsg) + } + +} + +func TestNewSignValidationErrf(t *testing.T) { + signErr := NewSignValidationErrf("error %s", "signature failed") + expected := "error signature failed" + if signErr.Error() != expected { + t.Errorf("err.Error() = %s, want %s", + signErr.Error(), expected) + } +} + +func TestNewSignValidationErr(t *testing.T) { + err := errors.New("signature error") + signErr := NewSignValidationErr(err) + + if signErr.Error() != err.Error() { + t.Errorf("err.Error() = %s, want %s", err.Error(), + signErr.Error()) + } +} + +func TestBadReqErr_BecknError(t *testing.T) { + badReqErr := NewBadReqErr(errors.New("invalid input")) + beErr := badReqErr.BecknError() + + expectedMsg := "BAD Request: invalid input" + if beErr.Message != expectedMsg { + t.Errorf("err.Error() = %s, want %s", + beErr.Message, expectedMsg) + } +} + +func TestNewBadReqErrf(t *testing.T) { + badReqErr := NewBadReqErrf("invalid field %s", "name") + expected := "invalid field name" + if badReqErr.Error() != expected { + t.Errorf("err.Error() = %s, want %s", + badReqErr, expected) + } +} + +func TestNewBadReqErr(t *testing.T) { + err := errors.New("bad request") + badReqErr := NewBadReqErr(err) + + if badReqErr.Error() != err.Error() { + t.Errorf("err.Error() = %s, want %s", + badReqErr.Error(), err.Error()) + } + +} + +func TestNotFoundErr_BecknError(t *testing.T) { + notFoundErr := NewNotFoundErr(errors.New("resource not found")) + beErr := notFoundErr.BecknError() + + expectedMsg := "Endpoint not found: resource not found" + if beErr.Message != expectedMsg { + t.Errorf("err.Error() = %s, want %s", + beErr.Message, expectedMsg) + } +} + +func TestNewNotFoundErrf(t *testing.T) { + notFoundErr := NewNotFoundErrf("resource %s not found", "user") + expected := "resource user not found" + if notFoundErr.Error() != expected { + t.Errorf("err.Error() = %s, want %s", + notFoundErr.Error(), expected) + } +} + +func TestNewNotFoundErr(t *testing.T) { + err := errors.New("not found") + notFoundErr := NewNotFoundErr(err) + + if notFoundErr.Error() != err.Error() { + t.Errorf("err.Error() = %s, want %s", + notFoundErr.Error(), err.Error()) + } +} + +func TestRole_UnmarshalYAML_ValidRole(t *testing.T) { + var role Role + yamlData := []byte("bap") + + err := yaml.Unmarshal(yamlData, &role) + assert.NoError(t, err) //TODO: should replace assert here + assert.Equal(t, RoleBAP, role) +} + +func TestRole_UnmarshalYAML_InvalidRole(t *testing.T) { + var role Role + yamlData := []byte("invalid") + + err := yaml.Unmarshal(yamlData, &role) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid Role") +} + +func TestSchemaValidationErr_BecknError_NoErrors(t *testing.T) { + schemaValidationErr := &SchemaValidationErr{Errors: nil} + beErr := schemaValidationErr.BecknError() + + expectedMsg := "Schema validation error." + expectedCode := http.StatusText(http.StatusBadRequest) + + if beErr.Message != expectedMsg { + t.Errorf("beErr.Message = %s, want %s", beErr.Message, expectedMsg) + } + if beErr.Code != expectedCode { + t.Errorf("beErr.Code = %s, want %s", beErr.Code, expectedCode) + } +} + +func TestParseContextKey_ValidKeys(t *testing.T) { + tests := []struct { + input string + expected ContextKey + }{ + {"transaction_id", ContextKeyTxnID}, + {"message_id", ContextKeyMsgID}, + {"subscriber_id", ContextKeySubscriberID}, + {"module_id", ContextKeyModuleID}, + } + + for _, tt := range tests { + key, err := ParseContextKey(tt.input) + if err != nil { + t.Errorf("unexpected error for input %s: %v", tt.input, err) + } + if key != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, key) + } + } +} + +func TestParseContextKey_InvalidKey(t *testing.T) { + _, err := ParseContextKey("invalid_key") + if err == nil { + t.Error("expected error for invalid context key, got nil") + } +} + +func TestContextKey_UnmarshalYAML_Valid(t *testing.T) { + yamlData := []byte("message_id") + var key ContextKey + + err := yaml.Unmarshal(yamlData, &key) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if key != ContextKeyMsgID { + t.Errorf("expected %s, got %s", ContextKeyMsgID, key) + } +} + +func TestContextKey_UnmarshalYAML_Invalid(t *testing.T) { + yamlData := []byte("invalid_key") + var key ContextKey + + err := yaml.Unmarshal(yamlData, &key) + if err == nil { + t.Error("expected error for invalid context key, got nil") + } +} diff --git a/pkg/model/model.go b/pkg/model/model.go new file mode 100644 index 0000000..a91e2a3 --- /dev/null +++ b/pkg/model/model.go @@ -0,0 +1,186 @@ +package model + +import ( + "context" + "fmt" + "net/http" + "net/url" + "time" +) + +// Subscriber represents a unique operational configuration of a trusted platform on a network. +type Subscriber struct { + SubscriberID string `json:"subscriber_id"` + URL string `json:"url" format:"uri"` + Type string `json:"type" enum:"BAP,BPP,BG"` + Domain string `json:"domain"` +} + +// Subscription represents subscription details of a network participant. +type Subscription struct { + Subscriber `json:",inline"` + KeyID string `json:"key_id" format:"uuid"` + SigningPublicKey string `json:"signing_public_key"` + EncrPublicKey string `json:"encr_public_key"` + ValidFrom time.Time `json:"valid_from" format:"date-time"` + ValidUntil time.Time `json:"valid_until" format:"date-time"` + Status string `json:"status" enum:"INITIATED,UNDER_SUBSCRIPTION,SUBSCRIBED,EXPIRED,UNSUBSCRIBED,INVALID_SSL"` + Created time.Time `json:"created" format:"date-time"` + Updated time.Time `json:"updated" format:"date-time"` + Nonce string +} + +// Authorization-related constants for headers. +const ( + AuthHeaderSubscriber string = "Authorization" + AuthHeaderGateway string = "X-Gateway-Authorization" + UnaAuthorizedHeaderSubscriber string = "WWW-Authenticate" + UnaAuthorizedHeaderGateway string = "Proxy-Authenticate" +) + +// ContextKey is a custom type used as a key for storing and retrieving values in a context. +type ContextKey string + +const ( + // ContextKeyTxnID is the context key used to store and retrieve the transaction ID in a request context. + ContextKeyTxnID ContextKey = "transaction_id" + + // ContextKeyMsgID is the context key used to store and retrieve the message ID in a request context. + ContextKeyMsgID ContextKey = "message_id" + + // ContextKeySubscriberID is the context key used to store and retrieve the subscriber ID in a request context. + ContextKeySubscriberID ContextKey = "subscriber_id" + + // ContextKeyModuleID is the context key for storing and retrieving the model ID from a request context. + ContextKeyModuleID ContextKey = "module_id" +) + +var contextKeys = map[string]ContextKey{ + "transaction_id": ContextKeyTxnID, + "message_id": ContextKeyMsgID, + "subscriber_id": ContextKeySubscriberID, + "module_id": ContextKeyModuleID, +} + +// ParseContextKey converts a string into a valid ContextKey. +func ParseContextKey(v string) (ContextKey, error) { + key, ok := contextKeys[v] + if !ok { + return "", fmt.Errorf("invalid context key: %s", v) + } + return key, nil +} + +// UnmarshalYAML ensures that only known context keys are accepted during YAML unmarshalling. +func (k *ContextKey) UnmarshalYAML(unmarshal func(interface{}) error) error { + var keyStr string + if err := unmarshal(&keyStr); err != nil { + return err + } + + parsedKey, err := ParseContextKey(keyStr) + if err != nil { + return err + } + + *k = parsedKey + return nil + +} + +// Role defines the type of participant in the network. +type Role string + +const ( + // RoleBAP represents a Buyer App Participant (BAP) in the network. + RoleBAP Role = "bap" + // RoleBPP represents a Buyer Platform Participant (BPP) in the network. + RoleBPP Role = "bpp" + // RoleGateway represents a Gateway that facilitates communication in the network. + RoleGateway Role = "gateway" + // RoleRegistery represents the Registry that maintains network participant details. + RoleRegistery Role = "registery" +) + +var validRoles = map[Role]bool{ + RoleBAP: true, + RoleBPP: true, + RoleGateway: true, + RoleRegistery: true, +} + +// UnmarshalYAML implements custom YAML unmarshalling for Role to ensure only valid values are accepted. +func (r *Role) UnmarshalYAML(unmarshal func(interface{}) error) error { + var roleName string + if err := unmarshal(&roleName); err != nil { + return err + } + + role := Role(roleName) + if !validRoles[role] { + return fmt.Errorf("invalid Role: %s", roleName) + } + *r = role + return nil +} + +// Route represents a network route for message processing. +type Route struct { + TargetType string // "url" or "publisher" + PublisherID string // For message queues + URL *url.URL // For API calls +} + +// Keyset represents a collection of cryptographic keys used for signing and encryption. +type Keyset struct { + UniqueKeyID string // UniqueKeyID is the identifier for the key pair. + SigningPrivate string // SigningPrivate is the private key used for signing operations. + SigningPublic string // SigningPublic is the public key corresponding to the signing private key. + EncrPrivate string // EncrPrivate is the private key used for encryption operations. + EncrPublic string // EncrPublic is the public key corresponding to the encryption private key. +} + +// StepContext holds context information for a request processing step. +type StepContext struct { + context.Context + Request *http.Request + Body []byte + Route *Route + SubID string + Role Role + RespHeader http.Header +} + +// WithContext updates the existing StepContext with a new context. +func (ctx *StepContext) WithContext(newCtx context.Context) { + ctx.Context = newCtx +} + +// Status represents the acknowledgment status in a response. +type Status string + +const ( + // StatusACK indicates a successful acknowledgment. + StatusACK Status = "ACK" + // StatusNACK indicates a negative acknowledgment or failure. + StatusNACK Status = "NACK" +) + +// Ack represents an acknowledgment response. +type Ack struct { + // Status holds the acknowledgment status (ACK/NACK). + Status Status `json:"status"` +} + +// Message represents the structure of a response message. +type Message struct { + // Ack contains the acknowledgment status. + Ack Ack `json:"ack"` + // Error holds error details, if any, in the response. + Error *Error `json:"error,omitempty"` +} + +// Response represents the main response structure. +type Response struct { + Message Message `json:"message"` +} diff --git a/pkg/plugin/config.go b/pkg/plugin/config.go new file mode 100644 index 0000000..c20170d --- /dev/null +++ b/pkg/plugin/config.go @@ -0,0 +1,21 @@ +package plugin + +type PublisherCfg struct { + ID string `yaml:"id"` + Config map[string]string `yaml:"config"` +} + +type ValidatorCfg struct { + ID string `yaml:"id"` + Config map[string]string `yaml:"config"` +} + +type Config struct { + ID string `yaml:"id"` + Config map[string]string `yaml:"config"` +} + +type ManagerConfig struct { + Root string `yaml:"root"` + RemoteRoot string `yaml:"remoteRoot"` +} diff --git a/pkg/plugin/definition/cache.go b/pkg/plugin/definition/cache.go new file mode 100644 index 0000000..488249f --- /dev/null +++ b/pkg/plugin/definition/cache.go @@ -0,0 +1,27 @@ +package definition + +import ( + "context" + "time" +) + +// Cache defines the general cache interface for caching plugins. +type Cache interface { + // Get retrieves a value from the cache based on the given key. + Get(ctx context.Context, key string) (string, error) + + // Set stores a value in the cache with the given key and TTL (time-to-live) in seconds. + Set(ctx context.Context, key, value string, ttl time.Duration) error + + // Delete removes a value from the cache based on the given key. + Delete(ctx context.Context, key string) error + + // Clear removes all values from the cache. + Clear(ctx context.Context) error +} + +// CacheProvider interface defines the contract for managing cache instances. +type CacheProvider interface { + // New initializes a new cache instance with the given configuration. + New(ctx context.Context, config map[string]string) (Cache, func() error, error) +} diff --git a/pkg/plugin/definition/decrypter.go b/pkg/plugin/definition/decrypter.go new file mode 100644 index 0000000..8bd0b6a --- /dev/null +++ b/pkg/plugin/definition/decrypter.go @@ -0,0 +1,15 @@ +package definition + +import "context" + +// Decrypter defines the methods for decryption. +type Decrypter interface { + // Decrypt decrypts the given body using the provided privateKeyBase64 and publicKeyBase64. + Decrypt(ctx context.Context, encryptedData string, privateKeyBase64, publicKeyBase64 string) (string, error) +} + +// DecrypterProvider initializes a new decrypter instance with the given config. +type DecrypterProvider interface { + // New creates a new decrypter instance based on the provided config. + New(ctx context.Context, config map[string]string) (Decrypter, func() error, error) +} diff --git a/pkg/plugin/definition/encrypter.go b/pkg/plugin/definition/encrypter.go new file mode 100644 index 0000000..08db8a6 --- /dev/null +++ b/pkg/plugin/definition/encrypter.go @@ -0,0 +1,15 @@ +package definition + +import "context" + +// Encrypter defines the methods for encryption. +type Encrypter interface { + // Encrypt encrypts the given body using the provided privateKeyBase64 and publicKeyBase64. + Encrypt(ctx context.Context, data string, privateKeyBase64, publicKeyBase64 string) (string, error) +} + +// EncrypterProvider initializes a new encrypter instance with the given config. +type EncrypterProvider interface { + // New creates a new encrypter instance based on the provided config. + New(ctx context.Context, config map[string]string) (Encrypter, func() error, error) +} diff --git a/pkg/plugin/definition/keymanager.go b/pkg/plugin/definition/keymanager.go new file mode 100644 index 0000000..f2c0e2f --- /dev/null +++ b/pkg/plugin/definition/keymanager.go @@ -0,0 +1,23 @@ +package definition + +import ( + "context" + + "github.com/beckn/beckn-onix/pkg/model" +) + +// KeyManager defines the interface for key management operations/methods. +type KeyManager interface { + GenerateKeyPairs() (*model.Keyset, error) + StorePrivateKeys(ctx context.Context, keyID string, keys *model.Keyset) error + SigningPrivateKey(ctx context.Context, keyID string) (string, string, error) + EncrPrivateKey(ctx context.Context, keyID string) (string, string, error) + SigningPublicKey(ctx context.Context, subscriberID, uniqueKeyID string) (string, error) + EncrPublicKey(ctx context.Context, subscriberID, uniqueKeyID string) (string, error) + DeletePrivateKeys(ctx context.Context, keyID string) error +} + +// KeyManagerProvider initializes a new signer instance. +type KeyManagerProvider interface { + New(context.Context, Cache, RegistryLookup, map[string]string) (KeyManager, func() error, error) +} diff --git a/pkg/plugin/definition/middleware.go b/pkg/plugin/definition/middleware.go new file mode 100644 index 0000000..7701ed5 --- /dev/null +++ b/pkg/plugin/definition/middleware.go @@ -0,0 +1,10 @@ +package definition + +import ( + "context" + "net/http" +) + +type MiddlewareProvider interface { + New(ctx context.Context, cfg map[string]string) (func(http.Handler) http.Handler, error) +} diff --git a/pkg/plugin/definition/publisher.go b/pkg/plugin/definition/publisher.go new file mode 100644 index 0000000..1e744da --- /dev/null +++ b/pkg/plugin/definition/publisher.go @@ -0,0 +1,14 @@ +package definition + +import "context" + +// Publisher defines the general publisher interface for messaging plugins. +type Publisher interface { + // Publish sends a message (as a byte slice) using the underlying messaging system. + Publish(context.Context, string, []byte) error +} + +type PublisherProvider interface { + // New initializes a new publisher instance with the given configuration. + New(ctx context.Context, config map[string]string) (Publisher, func() error, error) +} diff --git a/pkg/plugin/definition/registry.go b/pkg/plugin/definition/registry.go new file mode 100644 index 0000000..22881f3 --- /dev/null +++ b/pkg/plugin/definition/registry.go @@ -0,0 +1,11 @@ +package definition + +import ( + "context" + + "github.com/beckn/beckn-onix/pkg/model" +) + +type RegistryLookup interface { + Lookup(ctx context.Context, req *model.Subscription) ([]model.Subscription, error) +} diff --git a/pkg/plugin/definition/router.go b/pkg/plugin/definition/router.go new file mode 100644 index 0000000..f30a1ca --- /dev/null +++ b/pkg/plugin/definition/router.go @@ -0,0 +1,19 @@ +package definition + +import ( + "context" + "net/url" + + "github.com/beckn/beckn-onix/pkg/model" +) + +// RouterProvider initializes the a new Router instance with the given config. +type RouterProvider interface { + New(ctx context.Context, config map[string]string) (Router, func() error, error) +} + +// Router defines the interface for routing requests. +type Router interface { + // Route determines the routing destination based on the request context. + Route(ctx context.Context, url *url.URL, body []byte) (*model.Route, error) +} diff --git a/pkg/plugin/definition/schemaValidator.go b/pkg/plugin/definition/schemaValidator.go new file mode 100644 index 0000000..dc69ca5 --- /dev/null +++ b/pkg/plugin/definition/schemaValidator.go @@ -0,0 +1,16 @@ +package definition + +import ( + "context" + "net/url" +) + +// SchemaValidator interface for schema validation. +type SchemaValidator interface { + Validate(ctx context.Context, url *url.URL, payload []byte) error +} + +// SchemaValidatorProvider interface for creating validators. +type SchemaValidatorProvider interface { + New(ctx context.Context, config map[string]string) (SchemaValidator, func() error, error) +} diff --git a/pkg/plugin/definition/signer.go b/pkg/plugin/definition/signer.go new file mode 100644 index 0000000..eff7bae --- /dev/null +++ b/pkg/plugin/definition/signer.go @@ -0,0 +1,17 @@ +package definition + +import "context" + +// Signer defines the method for signing. +type Signer interface { + // Sign generates a signature for the given body and privateKeyBase64. + // The signature is created with the given timestamps: createdAt (signature creation time) + // and expiresAt (signature expiration time). + Sign(ctx context.Context, body []byte, privateKeyBase64 string, createdAt, expiresAt int64) (string, error) +} + +// SignerProvider initializes a new signer instance with the given config. +type SignerProvider interface { + // New creates a new signer instance based on the provided config. + New(ctx context.Context, config map[string]string) (Signer, func() error, error) +} diff --git a/pkg/plugin/definition/signvalidator.go b/pkg/plugin/definition/signvalidator.go new file mode 100644 index 0000000..e900a37 --- /dev/null +++ b/pkg/plugin/definition/signvalidator.go @@ -0,0 +1,15 @@ +package definition + +import "context" + +// SignValidator defines the method for verifying signatures. +type SignValidator interface { + // Validate checks the validity of the signature for the given body. + Validate(ctx context.Context, body []byte, header string, publicKeyBase64 string) error +} + +// SignValidatorProvider initializes a new Verifier instance with the given config. +type SignValidatorProvider interface { + // New creates a new Verifier instance based on the provided config. + New(ctx context.Context, config map[string]string) (SignValidator, func() error, error) +} diff --git a/pkg/plugin/definition/step.go b/pkg/plugin/definition/step.go new file mode 100644 index 0000000..627675a --- /dev/null +++ b/pkg/plugin/definition/step.go @@ -0,0 +1,15 @@ +package definition + +import ( + "context" + + "github.com/beckn/beckn-onix/pkg/model" +) + +type Step interface { + Run(ctx *model.StepContext) error +} + +type StepProvider interface { + New(context.Context, map[string]string) (Step, func(), error) +} diff --git a/pkg/plugin/implementation/decrypter/cmd/plugin.go b/pkg/plugin/implementation/decrypter/cmd/plugin.go new file mode 100644 index 0000000..628e2cb --- /dev/null +++ b/pkg/plugin/implementation/decrypter/cmd/plugin.go @@ -0,0 +1,19 @@ +package main + +import ( + "context" + + "github.com/beckn/beckn-onix/pkg/plugin/definition" + decrypter "github.com/beckn/beckn-onix/pkg/plugin/implementation/decrypter" +) + +// decrypterProvider implements the definition.decrypterProvider interface. +type decrypterProvider struct{} + +// New creates a new Decrypter instance using the provided configuration. +func (dp decrypterProvider) New(ctx context.Context, config map[string]string) (definition.Decrypter, func() error, error) { + return decrypter.New(ctx) +} + +// Provider is the exported symbol that the plugin manager will look for. +var Provider = decrypterProvider{} diff --git a/pkg/plugin/implementation/decrypter/cmd/plugin_test.go b/pkg/plugin/implementation/decrypter/cmd/plugin_test.go new file mode 100644 index 0000000..0e8a079 --- /dev/null +++ b/pkg/plugin/implementation/decrypter/cmd/plugin_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "context" + "testing" +) + +func TestDecrypterProviderSuccess(t *testing.T) { + tests := []struct { + name string + ctx context.Context + config map[string]string + }{ + { + name: "Valid context with empty config", + ctx: context.Background(), + config: map[string]string{}, + }, + { + name: "Valid context with non-empty config", + ctx: context.Background(), + config: map[string]string{"key": "value"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider := decrypterProvider{} + decrypter, cleanup, err := provider.New(tt.ctx, tt.config) + + // Check error. + if err != nil { + t.Errorf("New() error = %v, want no error", err) + } + + // Check decrypter. + if decrypter == nil { + t.Error("New() decrypter is nil, want non-nil") + } + + // Test cleanup function if it exists. + if cleanup != nil { + if err := cleanup(); err != nil { + t.Errorf("cleanup() error = %v", err) + } + } + }) + } +} diff --git a/pkg/plugin/implementation/decrypter/decrypter.go b/pkg/plugin/implementation/decrypter/decrypter.go new file mode 100644 index 0000000..f03d438 --- /dev/null +++ b/pkg/plugin/implementation/decrypter/decrypter.go @@ -0,0 +1,87 @@ +package decryption + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "encoding/base64" + "fmt" + + "github.com/zenazn/pkcs7pad" + + "github.com/beckn/beckn-onix/pkg/model" +) + +// decrypter implements the Decrypter interface and handles the decryption process. +type decrypter struct { +} + +// New creates a new decrypter instance with the given configuration. +func New(ctx context.Context) (*decrypter, func() error, error) { + return &decrypter{}, nil, nil +} + +// Decrypt decrypts the given encryptedData using the provided privateKeyBase64 and publicKeyBase64. +func (d *decrypter) Decrypt(ctx context.Context, encryptedData, privateKeyBase64, publicKeyBase64 string) (string, error) { + privateKeyBytes, err := base64.StdEncoding.DecodeString(privateKeyBase64) + if err != nil { + return "", model.NewBadReqErr(fmt.Errorf("invalid private key: %w", err)) + } + + publicKeyBytes, err := base64.StdEncoding.DecodeString(publicKeyBase64) + if err != nil { + return "", model.NewBadReqErr(fmt.Errorf("invalid public key: %w", err)) + } + + // Decode the Base64 encoded encrypted data. + messageByte, err := base64.StdEncoding.DecodeString(encryptedData) + if err != nil { + return "", model.NewBadReqErr(fmt.Errorf("failed to decode encrypted data: %w", err)) + } + + aesCipher, err := createAESCipher(privateKeyBytes, publicKeyBytes) + if err != nil { + return "", fmt.Errorf("failed to create AES cipher: %w", err) + } + + blocksize := aesCipher.BlockSize() + if len(messageByte)%blocksize != 0 { + return "", fmt.Errorf("ciphertext is not a multiple of the blocksize") + } + + for i := 0; i < len(messageByte); i += aesCipher.BlockSize() { + executionSlice := messageByte[i : i+aesCipher.BlockSize()] + aesCipher.Decrypt(executionSlice, executionSlice) + } + + messageByte, err = pkcs7pad.Unpad(messageByte) + if err != nil { + return "", fmt.Errorf("failed to unpad data: %w", err) + } + + return string(messageByte), nil +} + +func createAESCipher(privateKey, publicKey []byte) (cipher.Block, error) { + x25519Curve := ecdh.X25519() + x25519PrivateKey, err := x25519Curve.NewPrivateKey(privateKey) + if err != nil { + return nil, fmt.Errorf("failed to create private key: %w", err) + } + x25519PublicKey, err := x25519Curve.NewPublicKey(publicKey) + if err != nil { + return nil, fmt.Errorf("failed to create public key: %w", err) + } + sharedSecret, err := x25519PrivateKey.ECDH(x25519PublicKey) + if err != nil { + return nil, fmt.Errorf("failed to derive shared secret: %w", err) + } + + aesCipher, err := aes.NewCipher(sharedSecret) + if err != nil { + return nil, fmt.Errorf("failed to create AES cipher: %w", err) + } + + return aesCipher, nil +} diff --git a/pkg/plugin/implementation/decrypter/decrypter_test.go b/pkg/plugin/implementation/decrypter/decrypter_test.go new file mode 100644 index 0000000..a2bbe11 --- /dev/null +++ b/pkg/plugin/implementation/decrypter/decrypter_test.go @@ -0,0 +1,251 @@ +package decryption + +import ( + "context" + "crypto/aes" + "crypto/ecdh" + "crypto/rand" + "encoding/base64" + "strings" + "testing" + + "github.com/zenazn/pkcs7pad" +) + +// Helper function to generate valid test keys. +func generateTestKeys(t *testing.T) (privateKeyB64, publicKeyB64 string) { + curve := ecdh.X25519() + privateKey, err := curve.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("Failed to generate private key: %v", err) + } + + publicKey := privateKey.PublicKey() + privateKeyB64 = base64.StdEncoding.EncodeToString(privateKey.Bytes()) + publicKeyB64 = base64.StdEncoding.EncodeToString(publicKey.Bytes()) + + return privateKeyB64, publicKeyB64 +} + +// Helper function to encrypt test data. +func encryptTestData(t *testing.T, data []byte, privateKeyBase64, publicKeyBase64 string) string { + privateKeyBytes, err := base64.StdEncoding.DecodeString(privateKeyBase64) + if err != nil { + t.Fatalf("Invalid private key: %v", err) + } + + publicKeyBytes, err := base64.StdEncoding.DecodeString(publicKeyBase64) + if err != nil { + t.Fatalf("Invalid public key: %v", err) + } + + x25519Curve := ecdh.X25519() + x25519PrivateKey, err := x25519Curve.NewPrivateKey(privateKeyBytes) + if err != nil { + t.Fatalf("Failed to create private key: %v", err) + } + x25519PublicKey, err := x25519Curve.NewPublicKey(publicKeyBytes) + if err != nil { + t.Fatalf("Failed to create public key: %v", err) + } + + // Generate shared secret for encryption. + sharedSecret, err := x25519PrivateKey.ECDH(x25519PublicKey) + if err != nil { + t.Fatalf("Failed to create shared secret: %v", err) + } + + // Create AES cipher. + block, err := aes.NewCipher(sharedSecret) + if err != nil { + t.Fatalf("Failed to create AES cipher: %v", err) + } + + // Pad the data. + paddedData := pkcs7pad.Pad(data, block.BlockSize()) + + // Encrypt the data. + ciphertext := make([]byte, len(paddedData)) + for i := 0; i < len(paddedData); i += block.BlockSize() { + block.Encrypt(ciphertext[i:i+block.BlockSize()], paddedData[i:i+block.BlockSize()]) + } + + return base64.StdEncoding.EncodeToString(ciphertext) +} + +// TestDecrypterSuccess tests successful decryption scenarios. +func TestDecrypterSuccess(t *testing.T) { + senderPrivateKeyB64, senderPublicKeyB64 := generateTestKeys(t) + receiverPrivateKeyB64, receiverPublicKeyB64 := generateTestKeys(t) + + tests := []struct { + name string + data []byte + }{ + { + name: "Valid decryption with small data", + data: []byte("test"), + }, + { + name: "Valid decryption with medium data", + data: []byte("medium length test data that spans multiple blocks"), + }, + { + name: "Valid decryption with empty data", + data: []byte{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Encrypt the test data. + encryptedData := encryptTestData(t, tt.data, senderPrivateKeyB64, receiverPublicKeyB64) + + decrypter, _, err := New(context.Background()) + if err != nil { + t.Fatalf("Failed to create decrypter: %v", err) + } + + result, err := decrypter.Decrypt(context.Background(), encryptedData, receiverPrivateKeyB64, senderPublicKeyB64) + if err != nil { + t.Errorf("Decrypt() error = %v", err) + } + + if err == nil { + if result != string(tt.data) { + t.Errorf("Decrypt() = %v, want %v", result, string(tt.data)) + } + } + }) + } +} + +// TestDecrypterFailure tests various failure scenarios. +func TestDecrypterFailure(t *testing.T) { + _, senderPublicKeyB64 := generateTestKeys(t) + receiverPrivateKeyB64, _ := generateTestKeys(t) + + tests := []struct { + name string + encryptedData string + privateKey string + publicKey string + expectedErr string + }{ + { + name: "Invalid private key format", + encryptedData: base64.StdEncoding.EncodeToString(make([]byte, 32)), + privateKey: "invalid-base64!@#$", + publicKey: senderPublicKeyB64, + expectedErr: "invalid private key", + }, + { + name: "Invalid public key format", + encryptedData: base64.StdEncoding.EncodeToString(make([]byte, 32)), + privateKey: receiverPrivateKeyB64, + publicKey: "invalid-base64!@#$", + expectedErr: "invalid public key", + }, + { + name: "Invalid encrypted data format", + encryptedData: "invalid-base64!@#$", + privateKey: receiverPrivateKeyB64, + publicKey: senderPublicKeyB64, + expectedErr: "failed to decode encrypted data", + }, + { + name: "Empty private key", + encryptedData: base64.StdEncoding.EncodeToString(make([]byte, 32)), + privateKey: "", + publicKey: senderPublicKeyB64, + expectedErr: "invalid private key", + }, + { + name: "Empty public key", + encryptedData: base64.StdEncoding.EncodeToString(make([]byte, 32)), + privateKey: receiverPrivateKeyB64, + publicKey: "", + expectedErr: "invalid public key", + }, + { + name: "Invalid base64 data", + encryptedData: "=invalid-base64", // Invalid encrypted data. + privateKey: receiverPrivateKeyB64, + publicKey: senderPublicKeyB64, + expectedErr: "failed to decode encrypted data", + }, + { + name: "Invalid private key size", + encryptedData: base64.StdEncoding.EncodeToString(make([]byte, 32)), + privateKey: base64.StdEncoding.EncodeToString([]byte("short")), + publicKey: senderPublicKeyB64, + expectedErr: "failed to create private key", + }, + { + name: "Invalid public key size", + encryptedData: base64.StdEncoding.EncodeToString(make([]byte, 32)), + privateKey: receiverPrivateKeyB64, + publicKey: base64.StdEncoding.EncodeToString([]byte("short")), + expectedErr: "failed to create public key", + }, + { + name: "Invalid block size", + encryptedData: base64.StdEncoding.EncodeToString([]byte("not-block-size")), + privateKey: receiverPrivateKeyB64, + publicKey: senderPublicKeyB64, + expectedErr: "ciphertext is not a multiple of the blocksize", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + decrypter, _, err := New(context.Background()) + if err != nil { + t.Fatalf("Failed to create decrypter: %v", err) + } + + _, err = decrypter.Decrypt(context.Background(), tt.encryptedData, tt.privateKey, tt.publicKey) + if err == nil { + t.Error("Expected error but got none") + } + + if err != nil { + if !strings.Contains(err.Error(), tt.expectedErr) { + t.Errorf("Expected error containing %q, got %q", tt.expectedErr, err.Error()) + } + } + }) + } +} + +// TestNewDecrypter tests the creation of new Decrypter instances. +func TestNewDecrypter(t *testing.T) { + tests := []struct { + name string + ctx context.Context + }{ + { + name: "Valid context", + ctx: context.Background(), + }, + { + name: "Nil context", + ctx: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + decrypter, _, err := New(tt.ctx) + if err != nil { + t.Errorf("New() error = %v", err) + } + + if err == nil { + if decrypter == nil { + t.Error("Expected non-nil decrypter") + } + } + }) + } +} diff --git a/pkg/plugin/implementation/encrypter/cmd/plugin.go b/pkg/plugin/implementation/encrypter/cmd/plugin.go new file mode 100644 index 0000000..31e0044 --- /dev/null +++ b/pkg/plugin/implementation/encrypter/cmd/plugin.go @@ -0,0 +1,18 @@ +package main + +import ( + "context" + + "github.com/beckn/beckn-onix/pkg/plugin/definition" + "github.com/beckn/beckn-onix/pkg/plugin/implementation/encrypter" +) + +// encrypterProvider implements the definition.encrypterProvider interface. +type encrypterProvider struct{} + +func (ep encrypterProvider) New(ctx context.Context, config map[string]string) (definition.Encrypter, func() error, error) { + return encrypter.New(ctx) +} + +// Provider is the exported symbol that the plugin manager will look for. +var Provider = encrypterProvider{} diff --git a/pkg/plugin/implementation/encrypter/cmd/plugin_test.go b/pkg/plugin/implementation/encrypter/cmd/plugin_test.go new file mode 100644 index 0000000..1f65450 --- /dev/null +++ b/pkg/plugin/implementation/encrypter/cmd/plugin_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "context" + "testing" +) + +func TestEncrypterProviderSuccess(t *testing.T) { + tests := []struct { + name string + ctx context.Context + config map[string]string + }{ + { + name: "Valid empty config", + ctx: context.Background(), + config: map[string]string{}, + }, + { + name: "Valid config with algorithm", + ctx: context.Background(), + config: map[string]string{ + "algorithm": "AES", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create provider and encrypter. + provider := encrypterProvider{} + encrypter, cleanup, err := provider.New(tt.ctx, tt.config) + if err != nil { + t.Fatalf("EncrypterProvider.New() error = %v", err) + } + if encrypter == nil { + t.Fatal("EncrypterProvider.New() returned nil encrypter") + } + defer func() { + if cleanup != nil { + if err := cleanup(); err != nil { + t.Errorf("Cleanup() error = %v", err) + } + } + }() + + }) + } +} diff --git a/pkg/plugin/implementation/encrypter/encrypter.go b/pkg/plugin/implementation/encrypter/encrypter.go new file mode 100644 index 0000000..301cf0a --- /dev/null +++ b/pkg/plugin/implementation/encrypter/encrypter.go @@ -0,0 +1,71 @@ +package encrypter + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "encoding/base64" + "fmt" + + "github.com/beckn/beckn-onix/pkg/model" + "github.com/zenazn/pkcs7pad" +) + +// encrypter implements the Encrypter interface and handles the encryption process. +type encrypter struct { +} + +// New creates a new encrypter instance with the given configuration. +func New(ctx context.Context) (*encrypter, func() error, error) { + return &encrypter{}, nil, nil +} + +func (e *encrypter) Encrypt(ctx context.Context, data string, privateKeyBase64, publicKeyBase64 string) (string, error) { + privateKeyBytes, err := base64.StdEncoding.DecodeString(privateKeyBase64) + if err != nil { + return "", model.NewBadReqErr(fmt.Errorf("invalid private key: %w", err)) + } + + publicKeyBytes, err := base64.StdEncoding.DecodeString(publicKeyBase64) + if err != nil { + return "", model.NewBadReqErr(fmt.Errorf("invalid public key: %w", err)) + } + + // Convert the input string to a byte slice. + dataByte := []byte(data) + aesCipher, err := createAESCipher(privateKeyBytes, publicKeyBytes) + if err != nil { + return "", fmt.Errorf("failed to create AES cipher: %w", err) + } + + dataByte = pkcs7pad.Pad(dataByte, aesCipher.BlockSize()) + for i := 0; i < len(dataByte); i += aesCipher.BlockSize() { + aesCipher.Encrypt(dataByte[i:i+aesCipher.BlockSize()], dataByte[i:i+aesCipher.BlockSize()]) + } + + return base64.StdEncoding.EncodeToString(dataByte), nil +} + +func createAESCipher(privateKey, publicKey []byte) (cipher.Block, error) { + x25519Curve := ecdh.X25519() + x25519PrivateKey, err := x25519Curve.NewPrivateKey(privateKey) + if err != nil { + return nil, model.NewBadReqErr(fmt.Errorf("failed to create private key: %w", err)) + } + x25519PublicKey, err := x25519Curve.NewPublicKey(publicKey) + if err != nil { + return nil, model.NewBadReqErr(fmt.Errorf("failed to create public key: %w", err)) + } + sharedSecret, err := x25519PrivateKey.ECDH(x25519PublicKey) + if err != nil { + return nil, fmt.Errorf("failed to derive shared secret: %w", err) + } + + aesCipher, err := aes.NewCipher(sharedSecret) + if err != nil { + return nil, fmt.Errorf("failed to create AES cipher: %w", err) + } + + return aesCipher, nil +} diff --git a/pkg/plugin/implementation/encrypter/encrypter_test.go b/pkg/plugin/implementation/encrypter/encrypter_test.go new file mode 100644 index 0000000..917bdc4 --- /dev/null +++ b/pkg/plugin/implementation/encrypter/encrypter_test.go @@ -0,0 +1,183 @@ +package encrypter + +import ( + "context" + "crypto/ecdh" + "crypto/rand" + "encoding/base64" + "strings" + "testing" +) + +// Helper function to generate a test X25519 key pair. +func generateTestKeyPair(t *testing.T) (string, string) { + curve := ecdh.X25519() + privateKey, err := curve.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("Failed to generate private key: %v", err) + } + + publicKeyBytes := privateKey.PublicKey().Bytes() + // Encode public and private key to base64. + publicKeyBase64 := base64.StdEncoding.EncodeToString(publicKeyBytes) + privateKeyBase64 := base64.StdEncoding.EncodeToString(privateKey.Bytes()) + + return publicKeyBase64, privateKeyBase64 +} + +// TestEncryptSuccess tests successful encryption scenarios. +func TestEncryptSuccess(t *testing.T) { + _, privateKey := generateTestKeyPair(t) + peerpublicKey, _ := generateTestKeyPair(t) + + tests := []struct { + name string + data string + pubKey string + privKey string + }{ + { + name: "Valid short message", + data: "Hello, World!", + pubKey: peerpublicKey, + privKey: privateKey, + }, + { + name: "Valid JSON message", + data: `{"key":"value"}`, + pubKey: peerpublicKey, + privKey: privateKey, + }, + { + name: "Valid empty message", + data: "", + pubKey: peerpublicKey, + privKey: privateKey, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + encrypter := &encrypter{} + encrypted, err := encrypter.Encrypt(context.Background(), tt.data, tt.privKey, tt.pubKey) + if err != nil { + t.Errorf("Encrypt() expected no error, but got: %v", err) + } + + // Verify the encrypted data is valid base64. + _, err = base64.StdEncoding.DecodeString(encrypted) + if err != nil { + t.Errorf("Encrypt() output is not valid base64: %v", err) + } + + // Since we can't decrypt without the ephemeral private key, + // we can only verify that encryption doesn't return empty data. + if encrypted == "" { + t.Error("Encrypt() returned empty string") + } + + // Verify the output is different from input (basic encryption check). + if encrypted == tt.data { + t.Error("Encrypt() output matches input, suggesting no encryption occurred") + } + + }) + } +} + +// TestEncryptFailure tests encryption failure scenarios. +func TestEncryptFailure(t *testing.T) { + // Generate a valid key pair for testing. + _, privateKey := generateTestKeyPair(t) + peerpublicKey, _ := generateTestKeyPair(t) + + tests := []struct { + name string + data string + publicKey string + privKey string + errorContains string + }{ + { + name: "Invalid public key format", + data: "test data", + publicKey: "invalid-base64!@#$", + privKey: privateKey, + errorContains: "invalid public key", + }, + { + name: "Invalid key bytes(public key)", + data: "test data", + publicKey: base64.StdEncoding.EncodeToString([]byte("invalid-key-bytes")), + privKey: privateKey, + errorContains: "failed to create public key", + }, + { + name: "Invalid key bytes(private key)", + data: "test data", + publicKey: peerpublicKey, + privKey: base64.StdEncoding.EncodeToString([]byte("invalid-key-bytes")), + errorContains: "failed to create private key", + }, + { + name: "Empty public key", + data: "test data", + publicKey: "", + privKey: privateKey, + errorContains: "invalid public key", + }, + { + name: "Too short key", + data: "test data", + publicKey: base64.StdEncoding.EncodeToString([]byte{1, 2, 3, 4}), + privKey: privateKey, + errorContains: "failed to create public key", + }, + { + name: "Invalid private key", + data: "test data", + publicKey: peerpublicKey, + privKey: "invalid-base64!@#$", + errorContains: "invalid private key", + }, + { + name: "Empty private key", + data: "test data", + publicKey: peerpublicKey, + privKey: "", + errorContains: "invalid private key", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + encrypter := &encrypter{} + _, err := encrypter.Encrypt(context.Background(), tt.data, tt.privKey, tt.publicKey) + if err != nil && !strings.Contains(err.Error(), tt.errorContains) { + t.Errorf("Encrypt() error = %v, want error containing %q", err, tt.errorContains) + } + }) + } +} + +// TestNew tests the creation of new encrypter instances. +func TestNew(t *testing.T) { + tests := []struct { + name string + ctx context.Context + }{ + { + name: "Success", + ctx: context.Background(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + encrypter, _, err := New(tt.ctx) + if err == nil && encrypter == nil { + t.Error("New() returned nil encrypter") + } + }) + } +} diff --git a/pkg/plugin/implementation/reqpreprocessor/cmd/plugin.go b/pkg/plugin/implementation/reqpreprocessor/cmd/plugin.go new file mode 100644 index 0000000..e5ae3e2 --- /dev/null +++ b/pkg/plugin/implementation/reqpreprocessor/cmd/plugin.go @@ -0,0 +1,24 @@ +package main + +import ( + "context" + "net/http" + "strings" + + "github.com/beckn/beckn-onix/pkg/plugin/implementation/reqpreprocessor" +) + +type provider struct{} + +func (p provider) New(ctx context.Context, c map[string]string) (func(http.Handler) http.Handler, error) { + config := &reqpreprocessor.Config{} + if role, ok := c["role"]; ok { + config.Role = role + } + if contextKeys, ok := c["contextKeys"]; ok { + config.ContextKeys = strings.Split(contextKeys, ",") + } + return reqpreprocessor.NewPreProcessor(config) +} + +var Provider = provider{} diff --git a/pkg/plugin/implementation/reqpreprocessor/cmd/plugin_test.go b/pkg/plugin/implementation/reqpreprocessor/cmd/plugin_test.go new file mode 100644 index 0000000..cb5c7e6 --- /dev/null +++ b/pkg/plugin/implementation/reqpreprocessor/cmd/plugin_test.go @@ -0,0 +1,122 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TODO: Will Split this into success and fail (two test cases) +func TestProviderNew(t *testing.T) { + testCases := []struct { + name string + config map[string]string + expectedError bool + expectedStatus int + prepareRequest func(req *http.Request) + }{ + { + name: "No Config", + config: map[string]string{}, + expectedError: true, + expectedStatus: http.StatusOK, + prepareRequest: func(req *http.Request) { + // Add minimal required headers. + req.Header.Set("context", "test-context") + req.Header.Set("transaction_id", "test-transaction") + }, + }, + { + name: "Success with BPP role", + config: map[string]string{ + "role": "bpp", + }, + expectedError: false, + expectedStatus: http.StatusOK, + prepareRequest: func(req *http.Request) { + // Add headers matching the check keys. + req.Header.Set("context", "test-context") + req.Header.Set("transaction_id", "test-transaction") + req.Header.Set("bpp_id", "bpp-456") + }, + }, + { + name: "Missing role configuration", + config: map[string]string{ + // No role specified + }, + expectedError: true, + prepareRequest: func(req *http.Request) { + req.Header.Set("context", "test-context") + req.Header.Set("transaction_id", "test-transaction") + }, + }, + { + name: "Invalid role configuration", + config: map[string]string{ + "role": "invalid-role", + }, + expectedError: true, + prepareRequest: func(req *http.Request) { + req.Header.Set("context", "test-context") + req.Header.Set("transaction_id", "test-transaction") + }, + }, + { + name: "passing the contextKeys", + config: map[string]string{ + "role": "bpp", + "contextKeys": "transaction_id,message_id", + }, + expectedError: false, + expectedStatus: http.StatusOK, + prepareRequest: func(req *http.Request) { + req.Header.Set("context", "test-context") + req.Header.Set("transaction_id", "test-transaction") + req.Header.Set("bpp_id", "bpp1") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + requestBody := `{ + "context": { + "transaction_id": "abc" + } + }` + + p := provider{} + middleware, err := p.New(context.Background(), tc.config) + if tc.expectedError { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.NotNil(t, middleware) + + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + req := httptest.NewRequest("POST", "/", strings.NewReader(requestBody)) + req.Header.Set("Content-Type", "application/json") + if tc.prepareRequest != nil { + tc.prepareRequest(req) + } + + w := httptest.NewRecorder() + middlewaredHandler := middleware(testHandler) + middlewaredHandler.ServeHTTP(w, req) + assert.Equal(t, tc.expectedStatus, w.Code, "Unexpected response status") + responseBody := w.Body.String() + t.Logf("Response Body: %s", responseBody) + + }) + } +} diff --git a/pkg/plugin/implementation/reqpreprocessor/reqpreprocessor.go b/pkg/plugin/implementation/reqpreprocessor/reqpreprocessor.go new file mode 100644 index 0000000..7ca2901 --- /dev/null +++ b/pkg/plugin/implementation/reqpreprocessor/reqpreprocessor.go @@ -0,0 +1,90 @@ +package reqpreprocessor + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "github.com/beckn/beckn-onix/pkg/log" + "github.com/beckn/beckn-onix/pkg/model" +) + +// Config represents the configuration for the request preprocessor middleware. +type Config struct { + Role string + ContextKeys []string +} + +const contextKey = "context" + +// NewPreProcessor returns a middleware that processes the incoming request, +// extracts the context field from the body, and adds relevant values (like subscriber ID). +func NewPreProcessor(cfg *Config) (func(http.Handler) http.Handler, error) { + if err := validateConfig(cfg); err != nil { + return nil, err + } + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + var req map[string]interface{} + ctx := r.Context() + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, "Failed to decode request body", http.StatusBadRequest) + return + } + + // Extract context from request. + reqContext, ok := req["context"].(map[string]interface{}) + if !ok { + http.Error(w, fmt.Sprintf("%s field not found or invalid.", contextKey), http.StatusBadRequest) + return + } + var subID any + switch cfg.Role { + case "bap": + subID = reqContext["bap_id"] + case "bpp": + subID = reqContext["bpp_id"] + } + if subID != nil { + log.Debugf(ctx, "adding subscriberId to request:%s, %v", model.ContextKeySubscriberID, subID) + ctx = context.WithValue(ctx, model.ContextKeySubscriberID, subID) + } + for _, key := range cfg.ContextKeys { + ctxKey, _ := model.ParseContextKey(key) + if v, ok := reqContext[key]; ok { + ctx = context.WithValue(ctx, ctxKey, v) + } + } + r.Body = io.NopCloser(bytes.NewBuffer(body)) + r.ContentLength = int64(len(body)) + r = r.WithContext(ctx) + next.ServeHTTP(w, r) + }) + }, nil +} + +func validateConfig(cfg *Config) error { + if cfg == nil { + return errors.New("config cannot be nil") + } + + if cfg.Role != "bap" && cfg.Role != "bpp" { + return errors.New("role must be either 'bap' or 'bpp'") + } + + for _, key := range cfg.ContextKeys { + if _, err := model.ParseContextKey(key); err != nil { + return err + } + } + return nil +} diff --git a/pkg/plugin/implementation/reqpreprocessor/reqpreprocessor_test.go b/pkg/plugin/implementation/reqpreprocessor/reqpreprocessor_test.go new file mode 100644 index 0000000..ed78d2d --- /dev/null +++ b/pkg/plugin/implementation/reqpreprocessor/reqpreprocessor_test.go @@ -0,0 +1,269 @@ +package reqpreprocessor + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/beckn/beckn-onix/pkg/model" +) + +// ToDo Separate Middleware creation and execution. +func TestNewPreProcessorSuccessCases(t *testing.T) { + tests := []struct { + name string + config *Config + requestBody map[string]any + expectedID string + }{ + { + name: "BAP role with valid context", + config: &Config{ + Role: "bap", + }, + requestBody: map[string]interface{}{ + "context": map[string]interface{}{ + "bap_id": "bap-123", + "message_id": "msg-123", + }, + "message": map[string]interface{}{ + "key": "value", + }, + }, + expectedID: "bap-123", + }, + { + name: "BPP role with valid context", + config: &Config{ + Role: "bpp", + }, + requestBody: map[string]interface{}{ + "context": map[string]interface{}{ + "bpp_id": "bpp-456", + "message_id": "msg-456", + }, + "message": map[string]interface{}{ + "key": "value", + }, + }, + expectedID: "bpp-456", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + middleware, err := NewPreProcessor(tt.config) + if err != nil { + t.Fatalf("NewPreProcessor() error = %v", err) + } + + bodyBytes, err := json.Marshal(tt.requestBody) + if err != nil { + t.Fatalf("Failed to marshal request body: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/test", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + + var gotSubID interface{} + + dummyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + gotSubID = ctx.Value(model.ContextKeySubscriberID) + w.WriteHeader(http.StatusOK) + + // Verify subscriber ID + subID := ctx.Value(model.ContextKeySubscriberID) + if subID == nil { + t.Errorf("Expected subscriber ID but got none %s", ctx) + return + } + + // Verify the correct ID was set based on role + expectedKey := "bap_id" + if tt.config.Role == "bpp" { + expectedKey = "bpp_id" + } + expectedID := tt.requestBody["context"].(map[string]interface{})[expectedKey] + if subID != expectedID { + t.Errorf("Expected subscriber ID %v, got %v", expectedID, subID) + } + }) + + middleware(dummyHandler).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected status code 200, but got %d", rec.Code) + return + } + + // Verify subscriber ID + if gotSubID == nil { + t.Error("Expected subscriber_id to be set in context but got nil") + return + } + + subID, ok := gotSubID.(string) + if !ok { + t.Errorf("Expected subscriber_id to be string, got %T", gotSubID) + return + } + + if subID != tt.expectedID { + t.Errorf("Expected subscriber_id %q, got %q", tt.expectedID, subID) + } + }) + } +} + +func TestNewPreProcessorErrorCases(t *testing.T) { + tests := []struct { + name string + config *Config + requestBody interface{} + expectedCode int + expectErr bool + errMsg string + }{ + { + name: "Missing context", + config: &Config{ + Role: "bap", + }, + requestBody: map[string]any{ + "otherKey": "value", + }, + expectedCode: http.StatusBadRequest, + expectErr: false, + errMsg: "context field not found or invalid", + }, + { + name: "Invalid context type", + config: &Config{ + Role: "bap", + }, + requestBody: map[string]any{ + "context": "not-a-map", + }, + expectedCode: http.StatusBadRequest, + expectErr: false, + errMsg: "context field not found or invalid", + }, + { + name: "Nil config", + config: nil, + requestBody: map[string]any{}, + expectedCode: http.StatusInternalServerError, + expectErr: true, + errMsg: "config cannot be nil", + }, + { + name: "Invalid role", + config: &Config{ + Role: "invalid-role", + }, + requestBody: map[string]interface{}{ + "context": map[string]interface{}{ + "bap_id": "bap-123", + }, + }, + expectedCode: http.StatusInternalServerError, + expectErr: true, + errMsg: "role must be either 'bap' or 'bpp'", + }, + { + name: "Missing subscriber ID", + config: &Config{ + Role: "bap", + }, + requestBody: map[string]interface{}{ + "context": map[string]interface{}{ + "message_id": "msg-123", + }, + }, + expectedCode: http.StatusOK, + expectErr: false, + }, + { + name: "Invalid JSON body", + config: &Config{ + Role: "bap", + }, + requestBody: "{invalid-json}", + expectedCode: http.StatusBadRequest, + expectErr: false, + errMsg: "failed to decode request body", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + middleware, err := NewPreProcessor(tt.config) + if tt.expectErr { + if err == nil { + t.Errorf("Expected an error for NewPreProcessor(%s), but got none", tt.config) + } else if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("Expected error to contain %q, got %v", tt.errMsg, err) + } + return + } + if err != nil { + t.Fatalf("Unexpected error while creating middleware: %v", err) + } + + bodyBytes, _ := json.Marshal(tt.requestBody) + req := httptest.NewRequest(http.MethodPost, "/test", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + dummyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + middleware(dummyHandler).ServeHTTP(rec, req) + + if rec.Code != tt.expectedCode { + t.Errorf("Expected status code %d, but got %d", tt.expectedCode, rec.Code) + } + }) + } +} + +func TestNewPreProcessorAddsSubscriberIDToContext(t *testing.T) { + cfg := &Config{Role: "bap"} + middleware, err := NewPreProcessor(cfg) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + samplePayload := map[string]interface{}{ + "context": map[string]interface{}{ + "bap_id": "bap.example.com", + }, + } + bodyBytes, _ := json.Marshal(samplePayload) + + var receivedSubscriberID interface{} + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedSubscriberID = r.Context().Value(model.ContextKeySubscriberID) + w.WriteHeader(http.StatusOK) + }) + + req := httptest.NewRequest("POST", "/", strings.NewReader(string(bodyBytes))) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + middleware(handler).ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("Expected status 200 OK, got %d", rr.Code) + } + if receivedSubscriberID != "bap.example.com" { + t.Errorf("Expected subscriber ID 'bap.example.com', got %v", receivedSubscriberID) + } +} diff --git a/pkg/plugin/implementation/router/cmd/plugin.go b/pkg/plugin/implementation/router/cmd/plugin.go new file mode 100644 index 0000000..d5d71e3 --- /dev/null +++ b/pkg/plugin/implementation/router/cmd/plugin.go @@ -0,0 +1,31 @@ +package main + +import ( + "context" + "errors" + + "github.com/beckn/beckn-onix/pkg/plugin/definition" + "github.com/beckn/beckn-onix/pkg/plugin/implementation/router" +) + +// RouterProvider provides instances of Router. +type RouterProvider struct{} + +// New initializes a new Router instance. +func (rp RouterProvider) New(ctx context.Context, config map[string]string) (definition.Router, func() error, error) { + if ctx == nil { + return nil, nil, errors.New("context cannot be nil") + } + + // Parse the routingConfig key from the config map + routingConfig, ok := config["routingConfig"] + if !ok { + return nil, nil, errors.New("routingConfig is required in the configuration") + } + return router.New(ctx, &router.Config{ + RoutingConfig: routingConfig, + }) +} + +// Provider is the exported symbol that the plugin manager will look for. +var Provider = RouterProvider{} diff --git a/pkg/plugin/implementation/router/cmd/plugin_test.go b/pkg/plugin/implementation/router/cmd/plugin_test.go new file mode 100644 index 0000000..c5ffe8e --- /dev/null +++ b/pkg/plugin/implementation/router/cmd/plugin_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// setupTestConfig creates a temporary directory and writes a sample routing rules file. +func setupTestConfig(t *testing.T) string { + t.Helper() + + // Get project root (assuming testData is in project root) + _, filename, _, _ := runtime.Caller(0) // Path to plugin_test.go + projectRoot := filepath.Dir(filepath.Dir(filename)) // Move up from cmd/ + yamlPath := filepath.Join(projectRoot, "testData", "bap_receiver.yaml") + + // Copy to temp file (to test file loading logic) + tempDir := t.TempDir() + tempPath := filepath.Join(tempDir, "routingRules.yaml") + content, err := os.ReadFile(yamlPath) + if err != nil { + t.Fatalf("Failed to read test file: %v", err) + } + if err := os.WriteFile(tempPath, content, 0644); err != nil { + t.Fatalf("Failed to create temp config: %v", err) + } + + return tempPath +} + +// TestRouterProviderSuccess tests successful router creation. +func TestRouterProviderSuccess(t *testing.T) { + rulesFilePath := setupTestConfig(t) + defer os.RemoveAll(filepath.Dir(rulesFilePath)) + + provider := RouterProvider{} + router, _, err := provider.New(context.Background(), map[string]string{ + "routingConfig": rulesFilePath, + }) + + if err != nil { + t.Fatalf("New() unexpected error: %v", err) + } + if router == nil { + t.Error("New() returned nil router, want non-nil") + } +} + +// TestRouterProviderFailure tests the RouterProvider implementation for failure cases. +func TestRouterProviderFailure(t *testing.T) { + rulesFilePath := setupTestConfig(t) + defer os.RemoveAll(filepath.Dir(rulesFilePath)) + + // Define test cases + tests := []struct { + name string + ctx context.Context + config map[string]string + wantErr string + }{ + { + name: "Empty routing config path", + ctx: context.Background(), + config: map[string]string{ + "routingConfig": "", + }, + wantErr: "failed to load routing rules: routingConfig path is empty", + }, + { + name: "Missing routing config key", + ctx: context.Background(), + config: map[string]string{}, + wantErr: "routingConfig is required in the configuration", + }, + { + name: "Nil context", + ctx: nil, + config: map[string]string{"routingConfig": rulesFilePath}, + wantErr: "context cannot be nil", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider := RouterProvider{} + _, _, err := provider.New(tt.ctx, tt.config) + + // Check for expected error + if err == nil { + t.Fatalf("New(%v, %v) = nil error, want error containing %q", tt.ctx, tt.config, tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("New(%v, %v) = %v, want error containing %q", tt.ctx, tt.config, err, tt.wantErr) + } + }) + } +} diff --git a/pkg/plugin/implementation/router/router.go b/pkg/plugin/implementation/router/router.go new file mode 100644 index 0000000..52e628b --- /dev/null +++ b/pkg/plugin/implementation/router/router.go @@ -0,0 +1,250 @@ +package router + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "os" + "path" + "strings" + + "github.com/beckn/beckn-onix/pkg/model" + + "gopkg.in/yaml.v3" +) + +// Config holds the configuration for the Router plugin. +type Config struct { + RoutingConfig string `json:"routingConfig"` +} + +// RoutingConfig represents the structure of the routing configuration file. +type routingConfig struct { + RoutingRules []routingRule `yaml:"routingRules"` +} + +// Router implements Router interface. +type Router struct { + rules map[string]map[string]map[string]*model.Route // domain -> version -> endpoint -> route +} + +// RoutingRule represents a single routing rule. +type routingRule struct { + Domain string `yaml:"domain"` + Version string `yaml:"version"` + TargetType string `yaml:"targetType"` // "url", "publisher", "bpp", or "bap" + Target target `yaml:"target,omitempty"` + Endpoints []string `yaml:"endpoints"` +} + +// Target contains destination-specific details. +type target struct { + URL string `yaml:"url,omitempty"` // URL for "url" or gateway endpoint for "bpp"/"bap" + PublisherID string `yaml:"publisherId,omitempty"` // For "msgq" type +} + +// TargetType defines possible target destinations. +const ( + targetTypeURL = "url" // Route to a specific URL + targetTypePublisher = "publisher" // Route to a publisher + targetTypeBPP = "bpp" // Route to a BPP endpoint + targetTypeBAP = "bap" // Route to a BAP endpoint +) + +// New initializes a new Router instance with the provided configuration. +// It loads and validates the routing rules from the specified YAML file. +// Returns an error if the configuration is invalid or the rules cannot be loaded. +func New(ctx context.Context, config *Config) (*Router, func() error, error) { + // Check if config is nil + if config == nil { + return nil, nil, fmt.Errorf("config cannot be nil") + } + router := &Router{ + rules: make(map[string]map[string]map[string]*model.Route), + } + + // Load rules at bootup + if err := router.loadRules(config.RoutingConfig); err != nil { + return nil, nil, fmt.Errorf("failed to load routing rules: %w", err) + } + return router, nil, nil +} + +// LoadRules reads and parses routing rules from the YAML configuration file. +func (r *Router) loadRules(configPath string) error { + if configPath == "" { + return fmt.Errorf("routingConfig path is empty") + } + data, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("error reading config file at %s: %w", configPath, err) + } + var config routingConfig + if err := yaml.Unmarshal(data, &config); err != nil { + return fmt.Errorf("error parsing YAML: %w", err) + } + + // Validate rules + if err := validateRules(config.RoutingRules); err != nil { + return fmt.Errorf("invalid routing rules: %w", err) + } + // Build the optimized rule map + for _, rule := range config.RoutingRules { + // Initialize domain map if not exists + if _, ok := r.rules[rule.Domain]; !ok { + r.rules[rule.Domain] = make(map[string]map[string]*model.Route) + } + + // Initialize version map if not exists + if _, ok := r.rules[rule.Domain][rule.Version]; !ok { + r.rules[rule.Domain][rule.Version] = make(map[string]*model.Route) + } + + // Add all endpoints for this rule + for _, endpoint := range rule.Endpoints { + var route *model.Route + switch rule.TargetType { + case targetTypePublisher: + route = &model.Route{ + TargetType: rule.TargetType, + PublisherID: rule.Target.PublisherID, + } + case targetTypeURL: + parsedURL, err := url.Parse(rule.Target.URL) + if err != nil { + return fmt.Errorf("invalid URL in rule: %w", err) + } + route = &model.Route{ + TargetType: rule.TargetType, + URL: parsedURL, + } + case targetTypeBPP, targetTypeBAP: + var parsedURL *url.URL + if rule.Target.URL != "" { + parsedURL, err = url.Parse(rule.Target.URL) + if err != nil { + return fmt.Errorf("invalid URL in rule: %w", err) + } + } + route = &model.Route{ + TargetType: rule.TargetType, + URL: parsedURL, + } + } + r.rules[rule.Domain][rule.Version][endpoint] = route + } + } + + return nil +} + +// validateRules performs basic validation on the loaded routing rules. +func validateRules(rules []routingRule) error { + for _, rule := range rules { + // Ensure domain, version, and TargetType are present + if rule.Domain == "" || rule.Version == "" || rule.TargetType == "" { + return fmt.Errorf("invalid rule: domain, version, and targetType are required") + } + + // Validate based on TargetType + switch rule.TargetType { + case targetTypeURL: + if rule.Target.URL == "" { + return fmt.Errorf("invalid rule: url is required for targetType 'url'") + } + if _, err := url.Parse(rule.Target.URL); err != nil { + return fmt.Errorf("invalid URL - %s: %w", rule.Target.URL, err) + } + case targetTypePublisher: + if rule.Target.PublisherID == "" { + return fmt.Errorf("invalid rule: publisherID is required for targetType 'publisher'") + } + case targetTypeBPP, targetTypeBAP: + if rule.Target.URL != "" { + if _, err := url.Parse(rule.Target.URL); err != nil { + return fmt.Errorf("invalid URL - %s defined in routing config for target type %s: %w", rule.Target.URL, rule.TargetType, err) + } + } + continue + default: + return fmt.Errorf("invalid rule: unknown targetType '%s'", rule.TargetType) + } + } + return nil +} + +// Route determines the routing destination based on the request context. +func (r *Router) Route(ctx context.Context, url *url.URL, body []byte) (*model.Route, error) { + // Parse the body to extract domain and version + var requestBody struct { + Context struct { + Domain string `json:"domain"` + Version string `json:"version"` + BPPURI string `json:"bpp_uri,omitempty"` + BAPURI string `json:"bap_uri,omitempty"` + } `json:"context"` + } + if err := json.Unmarshal(body, &requestBody); err != nil { + return nil, fmt.Errorf("error parsing request body: %w", err) + } + + // Extract the endpoint from the URL + endpoint := path.Base(url.Path) + + // Lookup route in the optimized map + domainRules, ok := r.rules[requestBody.Context.Domain] + if !ok { + return nil, fmt.Errorf("no routing rules found for domain %s", requestBody.Context.Domain) + } + + versionRules, ok := domainRules[requestBody.Context.Version] + if !ok { + return nil, fmt.Errorf("no routing rules found for domain %s version %s", requestBody.Context.Domain, requestBody.Context.Version) + } + + route, ok := versionRules[endpoint] + if !ok { + return nil, fmt.Errorf("endpoint '%s' is not supported for domain %s and version %s in routing config", + endpoint, requestBody.Context.Domain, requestBody.Context.Version) + } + // Handle BPP/BAP routing with request URIs + switch route.TargetType { + case targetTypeBPP: + return handleProtocolMapping(route, requestBody.Context.BPPURI, endpoint) + case targetTypeBAP: + return handleProtocolMapping(route, requestBody.Context.BAPURI, endpoint) + } + return route, nil +} + +// handleProtocolMapping handles both BPP and BAP routing with proper URL construction +func handleProtocolMapping(route *model.Route, npURI, endpoint string) (*model.Route, error) { + target := strings.TrimSpace(npURI) + if len(target) == 0 { + if route.URL == nil { + return nil, fmt.Errorf("could not determine destination for endpoint '%s': neither request contained a %s URI nor was a default URL configured in routing rules", endpoint, strings.ToUpper(route.TargetType)) + } + return &model.Route{ + TargetType: targetTypeURL, + URL: &url.URL{ + Scheme: route.URL.Scheme, + Host: route.URL.Host, + Path: path.Join(route.URL.Path, endpoint), + }, + }, nil + } + targetURL, err := url.Parse(target) + if err != nil { + return nil, fmt.Errorf("invalid %s URI - %s in request body for %s: %w", strings.ToUpper(route.TargetType), target, endpoint, err) + } + + return &model.Route{ + TargetType: targetTypeURL, + URL: &url.URL{ + Scheme: targetURL.Scheme, + Host: targetURL.Host, + Path: path.Join(targetURL.Path, endpoint), + }, + }, nil +} diff --git a/pkg/plugin/implementation/router/router_test.go b/pkg/plugin/implementation/router/router_test.go new file mode 100644 index 0000000..d0bb271 --- /dev/null +++ b/pkg/plugin/implementation/router/router_test.go @@ -0,0 +1,486 @@ +package router + +import ( + "context" + "embed" + "net/url" + "os" + "path/filepath" + "strings" + "testing" +) + +//go:embed testData/* +var testData embed.FS + +func setupTestConfig(t *testing.T, yamlFileName string) string { + t.Helper() + configDir := t.TempDir() + + content, err := testData.ReadFile("testData/" + yamlFileName) + if err != nil { + t.Fatalf("ReadFile() err = %v, want nil", err) + } + + rulesPath := filepath.Join(configDir, "routing_rules.yaml") + if err := os.WriteFile(rulesPath, content, 0644); err != nil { + t.Fatalf("WriteFile() err = %v, want nil", err) + } + + return rulesPath +} + +// setupRouter is a helper function to create router instance. +func setupRouter(t *testing.T, configFile string) (*Router, func() error, string) { + rulesFilePath := setupTestConfig(t, configFile) + config := &Config{ + RoutingConfig: rulesFilePath, + } + router, _, err := New(context.Background(), config) + if err != nil { + t.Fatalf("New failed: %v", err) + } + return router, nil, rulesFilePath +} + +// TestNew tests the New function. +func TestNew(t *testing.T) { + ctx := context.Background() + + // List of YAML files in the testData directory + yamlFiles := []string{ + "bap_caller.yaml", + "bap_receiver.yaml", + "bpp_caller.yaml", + "bpp_receiver.yaml", + } + + for _, yamlFile := range yamlFiles { + t.Run(yamlFile, func(t *testing.T) { + rulesFilePath := setupTestConfig(t, yamlFile) + defer os.RemoveAll(filepath.Dir(rulesFilePath)) + + // Define test cases + tests := []struct { + name string + config *Config + wantErr string + }{ + { + name: "Valid configuration", + config: &Config{ + RoutingConfig: rulesFilePath, + }, + wantErr: "", + }, + { + name: "Empty config", + config: nil, + wantErr: "config cannot be nil", + }, + { + name: "Empty routing config path", + config: &Config{ + RoutingConfig: "", + }, + wantErr: "routingConfig path is empty", + }, + { + name: "Routing config file does not exist", + config: &Config{ + RoutingConfig: "/nonexistent/path/to/rules.yaml", + }, + wantErr: "error reading config file", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router, _, err := New(ctx, tt.config) + + // Check for expected error + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("New(%v) = %v, want error containing %q", tt.config, err, tt.wantErr) + } + return + } + + // Ensure no error occurred + if err != nil { + t.Errorf("New(%v) = %v, want nil error", tt.config, err) + return + } + + // Ensure the router and close function are not nil + if router == nil { + t.Errorf("New(%v, %v) = nil router, want non-nil", ctx, tt.config) + } + }) + } + }) + } +} + +// TestValidateRulesSuccess tests the validate function for success cases. +func TestValidateRulesSuccess(t *testing.T) { + tests := []struct { + name string + rules []routingRule + }{ + { + name: "Valid rules with url routing", + rules: []routingRule{ + { + Domain: "retail", + Version: "1.0.0", + TargetType: "url", + Target: target{ + URL: "https://example.com/api", + }, + Endpoints: []string{"on_search", "on_select"}, + }, + }, + }, + { + name: "Valid rules with publisher routing", + rules: []routingRule{ + { + Domain: "retail", + Version: "1.0.0", + TargetType: "publisher", + Target: target{ + PublisherID: "example_topic", + }, + Endpoints: []string{"on_search", "on_select"}, + }, + }, + }, + { + name: "Valid rules with bpp routing to gateway", + rules: []routingRule{ + { + Domain: "retail", + Version: "1.0.0", + TargetType: "bpp", + Target: target{ + URL: "https://mock_gateway.com/api", + }, + Endpoints: []string{"search"}, + }, + }, + }, + { + name: "Valid rules with bpp routing", + rules: []routingRule{ + { + Domain: "retail", + Version: "1.0.0", + TargetType: "bpp", + Endpoints: []string{"select"}, + }, + }, + }, + { + name: "Valid rules with bap routing", + rules: []routingRule{ + { + Domain: "retail", + Version: "1.0.0", + TargetType: "bap", + Endpoints: []string{"select"}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateRules(tt.rules) + if err != nil { + t.Errorf("validateRules(%v) = %v, want nil error", tt.rules, err) + } + }) + } +} + +// TestValidateRulesFailure tests the validate function for failure cases. +func TestValidateRulesFailure(t *testing.T) { + tests := []struct { + name string + rules []routingRule + wantErr string + }{ + { + name: "Missing domain", + rules: []routingRule{ + { + Version: "1.0.0", + TargetType: "url", + Target: target{ + URL: "https://example.com/api", + }, + Endpoints: []string{"search", "select"}, + }, + }, + wantErr: "invalid rule: domain, version, and targetType are required", + }, + { + name: "Missing version", + rules: []routingRule{ + { + Domain: "retail", + TargetType: "url", + Target: target{ + URL: "https://example.com/api", + }, + Endpoints: []string{"search", "select"}, + }, + }, + wantErr: "invalid rule: domain, version, and targetType are required", + }, + { + name: "Missing targetType", + rules: []routingRule{ + { + Domain: "retail", + Version: "1.0.0", + Target: target{ + URL: "https://example.com/api", + }, + Endpoints: []string{"search", "select"}, + }, + }, + wantErr: "invalid rule: domain, version, and targetType are required", + }, + { + name: "Invalid targetType", + rules: []routingRule{ + { + Domain: "retail", + Version: "1.0.0", + TargetType: "invalid", + Target: target{ + URL: "https://example.com/api", + }, + Endpoints: []string{"search", "select"}, + }, + }, + wantErr: "invalid rule: unknown targetType 'invalid'", + }, + { + name: "Missing url for targetType: url", + rules: []routingRule{ + { + Domain: "retail", + Version: "1.0.0", + TargetType: "url", + Target: target{ + // URL is missing + }, + Endpoints: []string{"search", "select"}, + }, + }, + wantErr: "invalid rule: url is required for targetType 'url'", + }, + { + name: "Invalid URL format for targetType: url", + rules: []routingRule{ + { + Domain: "retail", + Version: "1.0.0", + TargetType: "url", + Target: target{ + URL: "htp:// invalid-url.com", // Invalid scheme + }, + Endpoints: []string{"search"}, + }, + }, + wantErr: `invalid URL - htp:// invalid-url.com: parse "htp:// invalid-url.com": invalid character " " in host name`, + }, + { + name: "Missing topic_id for targetType: publisher", + rules: []routingRule{ + { + Domain: "retail", + Version: "1.0.0", + TargetType: "publisher", + Target: target{ + // PublisherID is missing + }, + Endpoints: []string{"search", "select"}, + }, + }, + wantErr: "invalid rule: publisherID is required for targetType 'publisher'", + }, + { + name: "Invalid URL for BPP targetType", + rules: []routingRule{ + { + Domain: "retail", + Version: "1.0.0", + TargetType: "bpp", + Target: target{ + URL: "htp:// invalid-url.com", // Invalid URL + }, + Endpoints: []string{"search"}, + }, + }, + wantErr: `invalid URL - htp:// invalid-url.com defined in routing config for target type bpp: parse "htp:// invalid-url.com": invalid character " " in host name`, + }, + { + name: "Invalid URL for BAP targetType", + rules: []routingRule{ + { + Domain: "retail", + Version: "1.0.0", + TargetType: "bap", + Target: target{ + URL: "http:// [invalid].com", // Invalid host + }, + Endpoints: []string{"search"}, + }, + }, + wantErr: `invalid URL - http:// [invalid].com defined in routing config for target type bap: parse "http:// [invalid].com": invalid character " " in host name`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateRules(tt.rules) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("validateRules(%v) = %v, want error containing %q", tt.rules, err, tt.wantErr) + } + }) + } +} + +// TestRouteSuccess tests the Route function for success cases. +func TestRouteSuccess(t *testing.T) { + ctx := context.Background() + + // Define success test cases + tests := []struct { + name string + configFile string + url string + body string + }{ + { + name: "Valid domain, version, and endpoint (bpp routing with gateway URL)", + configFile: "bap_caller.yaml", + url: "https://example.com/v1/ondc/search", + body: `{"context": {"domain": "ONDC:TRV10", "version": "2.0.0"}}`, + }, + { + name: "Valid domain, version, and endpoint (bpp routing with bpp_uri)", + configFile: "bap_caller.yaml", + url: "https://example.com/v1/ondc/select", + body: `{"context": {"domain": "ONDC:TRV10", "version": "2.0.0", "bpp_uri": "https://bpp1.example.com"}}`, + }, + { + name: "Valid domain, version, and endpoint (url routing)", + configFile: "bpp_receiver.yaml", + url: "https://example.com/v1/ondc/select", + body: `{"context": {"domain": "ONDC:TRV10", "version": "2.0.0"}}`, + }, + { + name: "Valid domain, version, and endpoint (publisher routing)", + configFile: "bpp_receiver.yaml", + url: "https://example.com/v1/ondc/search", + body: `{"context": {"domain": "ONDC:TRV10", "version": "2.0.0"}}`, + }, + { + name: "Valid domain, version, and endpoint (bap routing with bap_uri)", + configFile: "bpp_caller.yaml", + url: "https://example.com/v1/ondc/on_select", + body: `{"context": {"domain": "ONDC:TRV10", "version": "2.0.0", "bap_uri": "https://bap1.example.com"}}`, + }, + { + name: "Valid domain, version, and endpoint (bpp routing with bpp_uri)", + configFile: "bap_receiver.yaml", + url: "https://example.com/v1/ondc/on_select", + body: `{"context": {"domain": "ONDC:TRV10", "version": "2.0.0", "bpp_uri": "https://bpp1.example.com"}}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router, _, rulesFilePath := setupRouter(t, tt.configFile) + defer os.RemoveAll(filepath.Dir(rulesFilePath)) + + parsedURL, _ := url.Parse(tt.url) + _, err := router.Route(ctx, parsedURL, []byte(tt.body)) + + // Ensure no error occurred + if err != nil { + t.Errorf("router.Route(%v, %v, %v) = %v, want nil error", ctx, parsedURL, []byte(tt.body), err) + } + }) + } +} + +// TestRouteFailure tests the Route function for failure cases. +func TestRouteFailure(t *testing.T) { + ctx := context.Background() + + // Define failure test cases + tests := []struct { + name string + configFile string + url string + body string + wantErr string + }{ + { + name: "Unsupported endpoint", + configFile: "bpp_receiver.yaml", + url: "https://example.com/v1/ondc/unsupported", + body: `{"context": {"domain": "ONDC:TRV11", "version": "2.0.0"}}`, + wantErr: "endpoint 'unsupported' is not supported for domain ONDC:TRV11 and version 2.0.0", + }, + { + name: "No matching rule", + configFile: "bpp_receiver.yaml", + url: "https://example.com/v1/ondc/select", + body: `{"context": {"domain": "ONDC:SRV11", "version": "2.0.0"}}`, + wantErr: "no routing rules found for domain ONDC:SRV11", + }, + { + name: "Missing bap_uri for bap routing", + configFile: "bpp_caller.yaml", + url: "https://example.com/v1/ondc/on_search", + body: `{"context": {"domain": "ONDC:TRV10", "version": "2.0.0"}}`, + wantErr: "could not determine destination for endpoint 'on_search': neither request contained a BAP URI nor was a default URL configured in routing rules", + }, + { + name: "Missing bpp_uri for bpp routing", + configFile: "bap_caller.yaml", + url: "https://example.com/v1/ondc/select", + body: `{"context": {"domain": "ONDC:TRV10", "version": "2.0.0"}}`, + wantErr: "could not determine destination for endpoint 'select': neither request contained a BPP URI nor was a default URL configured in routing rules", + }, + { + name: "Invalid bpp_uri format in request", + configFile: "bap_caller.yaml", + url: "https://example.com/v1/ondc/select", + body: `{"context": {"domain": "ONDC:TRV10", "version": "2.0.0", "bpp_uri": "htp:// invalid-url"}}`, // Invalid scheme (htp instead of http) + wantErr: `invalid BPP URI - htp:// invalid-url in request body for select: parse "htp:// invalid-url": invalid character " " in host name`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router, _, rulesFilePath := setupRouter(t, tt.configFile) + defer os.RemoveAll(filepath.Dir(rulesFilePath)) + + parsedURL, _ := url.Parse(tt.url) + _, err := router.Route(ctx, parsedURL, []byte(tt.body)) + + // Check for expected error + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("Route(%q, %q) = %v, want error containing %q", tt.url, tt.body, err, tt.wantErr) + } + }) + } +} diff --git a/pkg/plugin/implementation/router/testData/bap_caller.yaml b/pkg/plugin/implementation/router/testData/bap_caller.yaml new file mode 100644 index 0000000..6a40a0f --- /dev/null +++ b/pkg/plugin/implementation/router/testData/bap_caller.yaml @@ -0,0 +1,25 @@ +routingRules: + - domain: ONDC:TRV10 + version: 2.0.0 + targetType: bpp + target: + url: https://gateway.example.com + endpoints: + - search + - domain: ONDC:TRV10 + version: 2.0.0 + targetType: bpp + endpoints: + - select + - init + - confirm + - status + - cancel + - domain: ONDC:TRV12 + version: 2.0.0 + targetType: bpp + endpoints: + - select + - init + - confirm + - status \ No newline at end of file diff --git a/pkg/plugin/implementation/router/testData/bap_receiver.yaml b/pkg/plugin/implementation/router/testData/bap_receiver.yaml new file mode 100644 index 0000000..17432db --- /dev/null +++ b/pkg/plugin/implementation/router/testData/bap_receiver.yaml @@ -0,0 +1,20 @@ +routingRules: + - domain: ONDC:TRV10 + version: 2.0.0 + targetType: url + target: + url: https://services-backend/trv/v1 + endpoints: + - on_select + - on_init + - on_confirm + - on_status + - on_update + - on_cancel + - domain: ONDC:TRV10 + version: 2.0.0 + targetType: publisher + target: + publisherId: trv_topic_id1 + endpoints: + - on_search \ No newline at end of file diff --git a/pkg/plugin/implementation/router/testData/bpp_caller.yaml b/pkg/plugin/implementation/router/testData/bpp_caller.yaml new file mode 100644 index 0000000..339b92d --- /dev/null +++ b/pkg/plugin/implementation/router/testData/bpp_caller.yaml @@ -0,0 +1,23 @@ +routingRules: + - domain: ONDC:TRV10 + version: 2.0.0 + targetType: bap + endpoints: + - on_search + - on_select + - on_init + - on_confirm + - on_status + - on_update + - on_cancel + - domain: ONDC:TRV11 + version: 2.0.0 + targetType: bap + endpoints: + - on_search + - on_select + - on_init + - on_confirm + - on_status + - on_update + - on_cancel \ No newline at end of file diff --git a/pkg/plugin/implementation/router/testData/bpp_receiver.yaml b/pkg/plugin/implementation/router/testData/bpp_receiver.yaml new file mode 100644 index 0000000..7355592 --- /dev/null +++ b/pkg/plugin/implementation/router/testData/bpp_receiver.yaml @@ -0,0 +1,28 @@ +routingRules: + - domain: ONDC:TRV10 + version: 2.0.0 + targetType: url + target: + url: https://services-backend/trv/v1 + endpoints: + - select + - init + - confirm + - status + - cancel + - domain: ONDC:TRV10 + version: 2.0.0 + targetType: publisher + target: + publisherId: trv_topic_id1 + endpoints: + - search + - domain: ONDC:TRV11 + version: 2.0.0 + targetType: url + target: + url: https://services-backend/trv/v1 + endpoints: + - select + - init + - confirm \ No newline at end of file diff --git a/pkg/plugin/implementation/schemaValidator.so b/pkg/plugin/implementation/schemaValidator.so deleted file mode 100644 index d90e575..0000000 Binary files a/pkg/plugin/implementation/schemaValidator.so and /dev/null differ diff --git a/pkg/plugin/implementation/schemavalidator/cmd/plugin.go b/pkg/plugin/implementation/schemavalidator/cmd/plugin.go new file mode 100644 index 0000000..f71aaaf --- /dev/null +++ b/pkg/plugin/implementation/schemavalidator/cmd/plugin.go @@ -0,0 +1,33 @@ +package main + +import ( + "context" + "errors" + + "github.com/beckn/beckn-onix/pkg/plugin/definition" + "github.com/beckn/beckn-onix/pkg/plugin/implementation/schemavalidator" +) + +// schemaValidatorProvider provides instances of schemaValidator. +type schemaValidatorProvider struct{} + +// New initializes a new Verifier instance. +func (vp schemaValidatorProvider) New(ctx context.Context, config map[string]string) (definition.SchemaValidator, func() error, error) { + if ctx == nil { + return nil, nil, errors.New("context cannot be nil") + } + + // Extract schemaDir from the config map + schemaDir, ok := config["schemaDir"] + if !ok || schemaDir == "" { + return nil, nil, errors.New("config must contain 'schemaDir'") + } + + // Create a new schemaValidator instance with the provided configuration + return schemavalidator.New(ctx, &schemavalidator.Config{ + SchemaDir: schemaDir, + }) +} + +// Provider is the exported symbol that the plugin manager will look for. +var Provider = schemaValidatorProvider{} diff --git a/pkg/plugin/implementation/schemavalidator/cmd/plugin_test.go b/pkg/plugin/implementation/schemavalidator/cmd/plugin_test.go new file mode 100644 index 0000000..75fdce0 --- /dev/null +++ b/pkg/plugin/implementation/schemavalidator/cmd/plugin_test.go @@ -0,0 +1,150 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// setupTestSchema creates a temporary directory and writes a sample schema file. +func setupTestSchema(t *testing.T) string { + t.Helper() + + // Create a temporary directory for the schema + schemaDir, err := os.MkdirTemp("", "schemas") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + + // Create the directory structure for the schema file + schemaFilePath := filepath.Join(schemaDir, "example", "1.0", "test_schema.json") + if err := os.MkdirAll(filepath.Dir(schemaFilePath), 0755); err != nil { + t.Fatalf("Failed to create schema directory structure: %v", err) + } + + // Define a sample schema + schemaContent := `{ + "type": "object", + "properties": { + "context": { + "type": "object", + "properties": { + "domain": {"type": "string"}, + "version": {"type": "string"} + }, + "required": ["domain", "version"] + } + }, + "required": ["context"] + }` + + // Write the schema to the file + if err := os.WriteFile(schemaFilePath, []byte(schemaContent), 0644); err != nil { + t.Fatalf("Failed to write schema file: %v", err) + } + + return schemaDir +} + +// TestValidatorProviderSuccess tests successful ValidatorProvider implementation. +func TestValidatorProviderSuccess(t *testing.T) { + schemaDir := setupTestSchema(t) + defer os.RemoveAll(schemaDir) + + // Define test cases. + tests := []struct { + name string + ctx context.Context + config map[string]string + expectedError string + }{ + { + name: "Valid schema directory", + ctx: context.Background(), // Valid context + config: map[string]string{"schemaDir": schemaDir}, + expectedError: "", + }, + } + + // Test using table-driven tests + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + vp := schemaValidatorProvider{} + schemaValidator, _, err := vp.New(tt.ctx, tt.config) + + // Ensure no error occurred + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + // Ensure the schemaValidator is not nil + if schemaValidator == nil { + t.Error("expected a non-nil schemaValidator, got nil") + } + }) + } +} + +// TestValidatorProviderSuccess tests cases where ValidatorProvider creation should fail. +func TestValidatorProviderFailure(t *testing.T) { + schemaDir := setupTestSchema(t) + defer os.RemoveAll(schemaDir) + + // Define test cases. + tests := []struct { + name string + ctx context.Context + config map[string]string + expectedError string + }{ + { + name: "Config is empty", + ctx: context.Background(), + config: map[string]string{}, + expectedError: "config must contain 'schemaDir'", + }, + { + name: "schemaDir is empty", + ctx: context.Background(), + config: map[string]string{"schemaDir": ""}, + expectedError: "config must contain 'schemaDir'", + }, + { + name: "Invalid schema directory", + ctx: context.Background(), // Valid context + config: map[string]string{"schemaDir": "/invalid/dir"}, + expectedError: "failed to initialise schemaValidator: schema directory does not exist: /invalid/dir", + }, + { + name: "Nil context", + ctx: nil, // Nil context + config: map[string]string{"schemaDir": schemaDir}, + expectedError: "context cannot be nil", + }, + } + + // Test using table-driven tests + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + vp := schemaValidatorProvider{} + _, _, err := vp.New(tt.ctx, tt.config) + + // Check for expected error + if tt.expectedError != "" { + if err == nil || !strings.Contains(err.Error(), tt.expectedError) { + t.Errorf("expected error %q, got %v", tt.expectedError, err) + } + return + } + + // Ensure no error occurred + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + }) + } +} diff --git a/pkg/plugin/implementation/schemavalidator/schemavalidator.go b/pkg/plugin/implementation/schemavalidator/schemavalidator.go new file mode 100644 index 0000000..5a04a4c --- /dev/null +++ b/pkg/plugin/implementation/schemavalidator/schemavalidator.go @@ -0,0 +1,193 @@ +package schemavalidator + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "os" + "path" + "path/filepath" + "strings" + + "github.com/beckn/beckn-onix/pkg/log" + "github.com/beckn/beckn-onix/pkg/model" + + "github.com/santhosh-tekuri/jsonschema/v6" +) + +// Payload represents the structure of the data payload with context information. +type payload struct { + Context struct { + Domain string `json:"domain"` + Version string `json:"version"` + } `json:"context"` +} + +// schemaValidator implements the Validator interface. +type schemaValidator struct { + config *Config + schemaCache map[string]*jsonschema.Schema +} + +// Config struct for SchemaValidator. +type Config struct { + SchemaDir string +} + +// New creates a new ValidatorProvider instance. +func New(ctx context.Context, config *Config) (*schemaValidator, func() error, error) { + // Check if config is nil + if config == nil { + return nil, nil, fmt.Errorf("config cannot be nil") + } + v := &schemaValidator{ + config: config, + schemaCache: make(map[string]*jsonschema.Schema), + } + + // Call Initialise function to load schemas and get validators + if err := v.initialise(); err != nil { + return nil, nil, fmt.Errorf("failed to initialise schemaValidator: %v", err) + } + return v, nil, nil +} + +// Validate validates the given data against the schema. +func (v *schemaValidator) Validate(ctx context.Context, url *url.URL, data []byte) error { + var payloadData payload + err := json.Unmarshal(data, &payloadData) + if err != nil { + return model.NewBadReqErr(fmt.Errorf("failed to parse JSON payload: %v", err)) + } + + // Extract domain, version, and endpoint from the payload and uri. + cxtDomain := payloadData.Context.Domain + version := payloadData.Context.Version + version = fmt.Sprintf("v%s", version) + + endpoint := path.Base(url.String()) + log.Debugf(ctx, "Handling request for endpoint: %s", endpoint) + domain := strings.ToLower(cxtDomain) + domain = strings.ReplaceAll(domain, ":", "_") + + // Construct the schema file name. + schemaFileName := fmt.Sprintf("%s_%s_%s", domain, version, endpoint) + + // Retrieve the schema from the cache. + schema, exists := v.schemaCache[schemaFileName] + if !exists { + return model.NewBadReqErr(fmt.Errorf("schema not found for domain: %s", domain)) + } + + var jsonData any + if err := json.Unmarshal(data, &jsonData); err != nil { + return model.NewBadReqErr(fmt.Errorf("failed to parse JSON data: %v", err)) + } + err = schema.Validate(jsonData) + if err != nil { + // Handle schema validation errors + if validationErr, ok := err.(*jsonschema.ValidationError); ok { + // Convert validation errors into an array of SchemaValError + var schemaErrors []model.Error + for _, cause := range validationErr.Causes { + // Extract the path and message from the validation error + path := strings.Join(cause.InstanceLocation, ".") // JSON path to the invalid field + message := cause.Error() // Validation error message + + // Append the error to the schemaErrors array + schemaErrors = append(schemaErrors, model.Error{ + Paths: path, + Message: message, + }) + } + // Return the array of schema validation errors + return &model.SchemaValidationErr{Errors: schemaErrors} + } + return fmt.Errorf("validation failed: %v", err) + } + + // Return nil if validation succeeds + return nil +} + +// Initialise initialises the validator provider by compiling all the JSON schema files +// from the specified directory and storing them in a cache indexed by their schema filenames. +func (v *schemaValidator) initialise() error { + schemaDir := v.config.SchemaDir + // Check if the directory exists and is accessible. + info, err := os.Stat(schemaDir) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("schema directory does not exist: %s", schemaDir) + } + return fmt.Errorf("failed to access schema directory: %v", err) + } + if !info.IsDir() { + return fmt.Errorf("provided schema path is not a directory: %s", schemaDir) + } + + compiler := jsonschema.NewCompiler() + + // Helper function to process directories recursively. + var processDir func(dir string) error + processDir = func(dir string) error { + entries, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("failed to read directory: %v", err) + } + + for _, entry := range entries { + path := filepath.Join(dir, entry.Name()) + if entry.IsDir() { + // Recursively process subdirectories. + if err := processDir(path); err != nil { + return err + } + } else if filepath.Ext(entry.Name()) == ".json" { + // Process JSON files. + compiledSchema, err := compiler.Compile(path) + if err != nil { + return fmt.Errorf("failed to compile JSON schema from file %s: %v", entry.Name(), err) + } + + // Use relative path from schemaDir to avoid absolute paths and make schema keys domain/version specific. + relativePath, err := filepath.Rel(schemaDir, path) + if err != nil { + return fmt.Errorf("failed to get relative path for file %s: %v", entry.Name(), err) + } + // Split the relative path to get domain, version, and schema. + parts := strings.Split(relativePath, string(os.PathSeparator)) + + // Ensure that the file path has at least 3 parts: domain, version, and schema file. + if len(parts) < 3 { + return fmt.Errorf("invalid schema file structure, expected domain/version/schema.json but got: %s", relativePath) + } + + // Extract domain, version, and schema filename from the parts. + // Validate that the extracted parts are non-empty. + domain := strings.TrimSpace(parts[0]) + version := strings.TrimSpace(parts[1]) + schemaFileName := strings.TrimSpace(parts[2]) + schemaFileName = strings.TrimSuffix(schemaFileName, ".json") + + if domain == "" || version == "" || schemaFileName == "" { + return fmt.Errorf("invalid schema file structure, one or more components are empty. Relative path: %s", relativePath) + } + + // Construct a unique key combining domain, version, and schema name (e.g., ondc_trv10_v2.0.0_schema). + uniqueKey := fmt.Sprintf("%s_%s_%s", domain, version, schemaFileName) + // Store the compiled schema in the SchemaCache using the unique key. + v.schemaCache[uniqueKey] = compiledSchema + } + } + return nil + } + + // Start processing from the root schema directory. + if err := processDir(schemaDir); err != nil { + return fmt.Errorf("failed to read schema directory: %v", err) + } + + return nil +} diff --git a/pkg/plugin/implementation/schemavalidator/schemavalidator_test.go b/pkg/plugin/implementation/schemavalidator/schemavalidator_test.go new file mode 100644 index 0000000..bdb4201 --- /dev/null +++ b/pkg/plugin/implementation/schemavalidator/schemavalidator_test.go @@ -0,0 +1,353 @@ +package schemavalidator + +import ( + "context" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/santhosh-tekuri/jsonschema/v6" +) + +// setupTestSchema creates a temporary directory and writes a sample schema file. +func setupTestSchema(t *testing.T) string { + t.Helper() + + // Create a temporary directory for the schema + schemaDir, err := os.MkdirTemp("", "schemas") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + + // Create the directory structure for the schema file + schemaFilePath := filepath.Join(schemaDir, "example", "v1.0", "endpoint.json") + if err := os.MkdirAll(filepath.Dir(schemaFilePath), 0755); err != nil { + t.Fatalf("Failed to create schema directory structure: %v", err) + } + + // Define a sample schema + schemaContent := `{ + "type": "object", + "properties": { + "context": { + "type": "object", + "properties": { + "domain": {"type": "string"}, + "version": {"type": "string"}, + "action": {"type": "string"} + }, + "required": ["domain", "version", "action"] + } + }, + "required": ["context"] + }` + + // Write the schema to the file + if err := os.WriteFile(schemaFilePath, []byte(schemaContent), 0644); err != nil { + t.Fatalf("Failed to write schema file: %v", err) + } + + return schemaDir +} + +func TestValidator_Validate_Success(t *testing.T) { + tests := []struct { + name string + url string + payload string + wantErr bool + }{ + { + name: "Valid payload", + url: "http://example.com/endpoint", + payload: `{"context": {"domain": "example", "version": "1.0", "action": "endpoint"}}`, + wantErr: false, + }, + } + + // Setup a temporary schema directory for testing + schemaDir := setupTestSchema(t) + defer os.RemoveAll(schemaDir) + + config := &Config{SchemaDir: schemaDir} + v, _, err := New(context.Background(), config) + if err != nil { + t.Fatalf("Failed to create validator: %v", err) + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u, _ := url.Parse(tt.url) + err := v.Validate(context.Background(), u, []byte(tt.payload)) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } else { + t.Logf("Test %s passed with no errors", tt.name) + } + }) + } +} + +func TestValidator_Validate_Failure(t *testing.T) { + tests := []struct { + name string + url string + payload string + wantErr string + }{ + { + name: "Invalid JSON payload", + url: "http://example.com/endpoint", + payload: `{"context": {"domain": "example", "version": "1.0"`, + wantErr: "failed to parse JSON payload", + }, + { + name: "Schema validation failure", + url: "http://example.com/endpoint", + payload: `{"context": {"domain": "example", "version": "1.0"}}`, + wantErr: "context: at '/context': missing property 'action'", + }, + { + name: "Schema not found", + url: "http://example.com/unknown_endpoint", + payload: `{"context": {"domain": "example", "version": "1.0"}}`, + wantErr: "schema not found for domain", + }, + } + + // Setup a temporary schema directory for testing + schemaDir := setupTestSchema(t) + defer os.RemoveAll(schemaDir) + + config := &Config{SchemaDir: schemaDir} + v, _, err := New(context.Background(), config) + if err != nil { + t.Fatalf("Failed to create validator: %v", err) + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u, _ := url.Parse(tt.url) + err := v.Validate(context.Background(), u, []byte(tt.payload)) + if tt.wantErr != "" { + if err == nil { + t.Errorf("Expected error containing '%s', but got nil", tt.wantErr) + } else if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("Expected error containing '%s', but got '%v'", tt.wantErr, err) + } else { + t.Logf("Test %s passed with expected error: %v", tt.name, err) + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } else { + t.Logf("Test %s passed with no errors", tt.name) + } + } + }) + } +} + +func TestValidator_Initialise(t *testing.T) { + tests := []struct { + name string + setupFunc func(schemaDir string) error + wantErr string + }{ + { + name: "Schema directory does not exist", + setupFunc: func(schemaDir string) error { + // Do not create the schema directory + return nil + + }, + wantErr: "schema directory does not exist", + }, + { + name: "Schema path is not a directory", + setupFunc: func(schemaDir string) error { + // Create a file instead of a directory + return os.WriteFile(schemaDir, []byte{}, 0644) + }, + wantErr: "provided schema path is not a directory", + }, + { + name: "Invalid schema file structure", + setupFunc: func(schemaDir string) error { + // Create an invalid schema file structure + invalidSchemaFile := filepath.Join(schemaDir, "invalid_schema.json") + if err := os.MkdirAll(filepath.Dir(invalidSchemaFile), 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + return os.WriteFile(invalidSchemaFile, []byte(`{}`), 0644) + }, + wantErr: "invalid schema file structure", + }, + { + name: "Failed to compile JSON schema", + setupFunc: func(schemaDir string) error { + // Create a schema file with invalid JSON + invalidSchemaFile := filepath.Join(schemaDir, "example", "1.0", "endpoint.json") + if err := os.MkdirAll(filepath.Dir(invalidSchemaFile), 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + return os.WriteFile(invalidSchemaFile, []byte(`{invalid json}`), 0644) + }, + wantErr: "failed to compile JSON schema", + }, + { + name: "Invalid schema file structure with empty components", + setupFunc: func(schemaDir string) error { + // Create a schema file with empty domain, version, or schema name + invalidSchemaFile := filepath.Join(schemaDir, "", "1.0", "endpoint.json") + if err := os.MkdirAll(filepath.Dir(invalidSchemaFile), 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + return os.WriteFile(invalidSchemaFile, []byte(`{ + "type": "object", + "properties": { + "context": { + "type": "object", + "properties": { + "domain": {"type": "string"}, + "version": {"type": "string"} + }, + "required": ["domain", "version"] + } + }, + "required": ["context"] + }`), 0644) + }, + wantErr: "failed to read schema directory: invalid schema file structure, expected domain/version/schema.json but got: 1.0/endpoint.json", + }, + { + name: "Failed to read directory", + setupFunc: func(schemaDir string) error { + // Create a directory and remove read permissions + if err := os.MkdirAll(schemaDir, 0000); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + return nil + }, + wantErr: "failed to read directory", + }, + { + name: "Valid schema directory", + setupFunc: func(schemaDir string) error { + // Create a valid schema file + validSchemaFile := filepath.Join(schemaDir, "example", "1.0", "endpoint.json") + if err := os.MkdirAll(filepath.Dir(validSchemaFile), 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + return os.WriteFile(validSchemaFile, []byte(`{ + "type": "object", + "properties": { + "context": { + "type": "object", + "properties": { + "domain": {"type": "string"}, + "version": {"type": "string"} + }, + "required": ["domain", "version"] + } + }, + "required": ["context"] + }`), 0644) + }, + wantErr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup a temporary schema directory for testing + schemaDir := filepath.Join(os.TempDir(), "schemas") + defer os.RemoveAll(schemaDir) + + // Run the setup function to prepare the test case + if err := tt.setupFunc(schemaDir); err != nil { + t.Fatalf("setupFunc() error = %v", err) + } + + config := &Config{SchemaDir: schemaDir} + v := &schemaValidator{ + config: config, + schemaCache: make(map[string]*jsonschema.Schema), + } + + err := v.initialise() + if (err != nil && !strings.Contains(err.Error(), tt.wantErr)) || (err == nil && tt.wantErr != "") { + t.Errorf("Error: initialise() returned error = %v, expected error = %v", err, tt.wantErr) + } else if err == nil { + t.Logf("Test %s passed: validator initialized successfully", tt.name) + } else { + t.Logf("Test %s passed with expected error: %v", tt.name, err) + } + }) + } +} + +func TestValidatorNew_Success(t *testing.T) { + schemaDir := setupTestSchema(t) + defer os.RemoveAll(schemaDir) + + config := &Config{SchemaDir: schemaDir} + _, _, err := New(context.Background(), config) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } +} + +func TestValidatorNewFailure(t *testing.T) { + tests := []struct { + name string + config *Config + setupFunc func(schemaDir string) error + wantErr string + }{ + { + name: "Config is nil", + config: nil, + setupFunc: func(schemaDir string) error { + return nil + }, + wantErr: "config cannot be nil", + }, + { + name: "Failed to initialise validators", + config: &Config{ + SchemaDir: "/invalid/path", + }, + setupFunc: func(schemaDir string) error { + // Do not create the schema directory + return nil + }, + wantErr: "ailed to initialise schemaValidator: schema directory does not exist: /invalid/path", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Run the setup function if provided + if tt.setupFunc != nil { + schemaDir := "" + if tt.config != nil { + schemaDir = tt.config.SchemaDir + } + if err := tt.setupFunc(schemaDir); err != nil { + t.Fatalf("Setup function failed: %v", err) + } + } + + // Call the New function with the test config + _, _, err := New(context.Background(), tt.config) + if (err != nil && !strings.Contains(err.Error(), tt.wantErr)) || (err == nil && tt.wantErr != "") { + t.Errorf("Error: New() returned error = %v, expected error = %v", err, tt.wantErr) + } else { + t.Logf("Test %s passed with expected error: %v", tt.name, err) + } + }) + } +} diff --git a/pkg/plugin/implementation/signer/cmd/plugin.go b/pkg/plugin/implementation/signer/cmd/plugin.go new file mode 100644 index 0000000..1df515f --- /dev/null +++ b/pkg/plugin/implementation/signer/cmd/plugin.go @@ -0,0 +1,24 @@ +package main + +import ( + "context" + "errors" + + "github.com/beckn/beckn-onix/pkg/plugin/definition" + "github.com/beckn/beckn-onix/pkg/plugin/implementation/signer" +) + +// SignerProvider implements the definition.SignerProvider interface. +type SignerProvider struct{} + +// New creates a new Signer instance using the provided configuration. +func (p SignerProvider) New(ctx context.Context, config map[string]string) (definition.Signer, func() error, error) { + if ctx == nil { + return nil, nil, errors.New("context cannot be nil") + } + + return signer.New(ctx, &signer.Config{}) +} + +// Provider is the exported symbol that the plugin manager will look for. +var Provider = SignerProvider{} diff --git a/pkg/plugin/implementation/signer/cmd/plugin_test.go b/pkg/plugin/implementation/signer/cmd/plugin_test.go new file mode 100644 index 0000000..e4730d5 --- /dev/null +++ b/pkg/plugin/implementation/signer/cmd/plugin_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "context" + "testing" +) + +// TestSignerProviderSuccess verifies successful scenarios for SignerProvider. +func TestSignerProviderSuccess(t *testing.T) { + provider := SignerProvider{} + + successTests := []struct { + name string + ctx context.Context + config map[string]string + }{ + { + name: "Valid Config", + ctx: context.Background(), + config: map[string]string{}, + }, + { + name: "Unexpected Config Key", + ctx: context.Background(), + config: map[string]string{"unexpected_key": "some_value"}, + }, + { + name: "Empty Config", + ctx: context.Background(), + config: map[string]string{}, + }, + { + name: "Config with empty TTL", + ctx: context.Background(), + config: map[string]string{"ttl": ""}, + }, + { + name: "Config with negative TTL", + ctx: context.Background(), + config: map[string]string{"ttl": "-100"}, + }, + { + name: "Config with non-numeric TTL", + ctx: context.Background(), + config: map[string]string{"ttl": "not_a_number"}, + }, + } + + for _, tt := range successTests { + t.Run(tt.name, func(t *testing.T) { + signer, close, err := provider.New(tt.ctx, tt.config) + + if err != nil { + t.Fatalf("Test %q failed: expected no error, but got: %v", tt.name, err) + } + if signer == nil { + t.Fatalf("Test %q failed: signer instance should not be nil", tt.name) + } + if close != nil { + if err := close(); err != nil { + t.Fatalf("Cleanup function returned an error: %v", err) + } + } + }) + } +} + +// TestSignerProviderFailure verifies failure scenarios for SignerProvider. +func TestSignerProviderFailure(t *testing.T) { + provider := SignerProvider{} + + failureTests := []struct { + name string + ctx context.Context + config map[string]string + wantErr bool + }{ + { + name: "Nil Context", + ctx: nil, + config: map[string]string{}, + wantErr: true, + }, + } + + for _, tt := range failureTests { + t.Run(tt.name, func(t *testing.T) { + signerInstance, close, err := provider.New(tt.ctx, tt.config) + + if (err != nil) != tt.wantErr { + t.Fatalf("Test %q failed: expected error: %v, got: %v", tt.name, tt.wantErr, err) + } + if signerInstance != nil { + t.Fatalf("Test %q failed: expected signer instance to be nil", tt.name) + } + if close != nil { + t.Fatalf("Test %q failed: expected cleanup function to be nil", tt.name) + } + }) + } +} diff --git a/pkg/plugin/implementation/signer/signer.go b/pkg/plugin/implementation/signer/signer.go new file mode 100644 index 0000000..1f5be86 --- /dev/null +++ b/pkg/plugin/implementation/signer/signer.go @@ -0,0 +1,73 @@ +package signer + +import ( + "context" + "crypto/ed25519" + "encoding/base64" + "errors" + "fmt" + + "golang.org/x/crypto/blake2b" +) + +// Config holds the configuration for the signing process. +type Config struct { +} + +// Signer implements the Signer interface and handles the signing process. +type Signer struct { + config *Config +} + +// New creates a new Signer instance with the given configuration. +func New(ctx context.Context, config *Config) (*Signer, func() error, error) { + s := &Signer{config: config} + + return s, nil, nil +} + +// hash generates a signing string using BLAKE-512 hashing. +func hash(payload []byte, createdAt, expiresAt int64) (string, error) { + hasher, _ := blake2b.New512(nil) + + _, err := hasher.Write(payload) + if err != nil { + return "", fmt.Errorf("failed to hash payload: %w", err) + } + + hashSum := hasher.Sum(nil) + digestB64 := base64.StdEncoding.EncodeToString(hashSum) + + return fmt.Sprintf("(created): %d\n(expires): %d\ndigest: BLAKE-512=%s", createdAt, expiresAt, digestB64), nil +} + +// generateSignature signs the given signing string using the provided private key. +func generateSignature(signingString []byte, privateKeyBase64 string) ([]byte, error) { + privateKeyBytes, err := base64.StdEncoding.DecodeString(privateKeyBase64) + if err != nil { + return nil, fmt.Errorf("error decoding private key: %w", err) + } + + if len(privateKeyBytes) != ed25519.SeedSize { + return nil, errors.New("invalid seed length") + } + + // Generate the private key from the seed + privateKey := ed25519.NewKeyFromSeed(privateKeyBytes) + return ed25519.Sign(privateKey, signingString), nil +} + +// Sign generates a digital signature for the provided payload. +func (s *Signer) Sign(ctx context.Context, body []byte, privateKeyBase64 string, createdAt, expiresAt int64) (string, error) { + signingString, err := hash(body, createdAt, expiresAt) + if err != nil { + return "", err + } + + signature, err := generateSignature([]byte(signingString), privateKeyBase64) + if err != nil { + return "", err + } + + return base64.StdEncoding.EncodeToString(signature), nil +} diff --git a/pkg/plugin/implementation/signer/signer_test.go b/pkg/plugin/implementation/signer/signer_test.go new file mode 100644 index 0000000..ebcbbb6 --- /dev/null +++ b/pkg/plugin/implementation/signer/signer_test.go @@ -0,0 +1,104 @@ +package signer + +import ( + "context" + "crypto/ed25519" + "encoding/base64" + "strings" + "testing" + "time" +) + +// generateTestKeys generates a test private and public key pair in base64 encoding. +func generateTestKeys() (string, string) { + publicKey, privateKey, _ := ed25519.GenerateKey(nil) + return base64.StdEncoding.EncodeToString(privateKey.Seed()), base64.StdEncoding.EncodeToString(publicKey) +} + +// TestSignSuccess tests the Sign method with valid inputs to ensure it produces a valid signature. +func TestSignSuccess(t *testing.T) { + privateKey, _ := generateTestKeys() + config := Config{} + signer, close, _ := New(context.Background(), &config) + + successTests := []struct { + name string + payload []byte + privateKey string + createdAt int64 + expiresAt int64 + }{ + { + name: "Valid Signing", + payload: []byte("test payload"), + privateKey: privateKey, + createdAt: time.Now().Unix(), + expiresAt: time.Now().Unix() + 3600, + }, + } + + for _, tt := range successTests { + t.Run(tt.name, func(t *testing.T) { + signature, err := signer.Sign(context.Background(), tt.payload, tt.privateKey, tt.createdAt, tt.expiresAt) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if len(signature) == 0 { + t.Errorf("expected a non-empty signature, but got empty") + } + if close != nil { + if err := close(); err != nil { + t.Fatalf("Cleanup function returned an error: %v", err) + } + } + }) + } +} + +// TestSignFailure tests the Sign method with invalid inputs to ensure proper error handling. +func TestSignFailure(t *testing.T) { + config := Config{} + signer, close, _ := New(context.Background(), &config) + + failureTests := []struct { + name string + payload []byte + privateKey string + createdAt int64 + expiresAt int64 + expectErrString string + }{ + { + name: "Invalid Private Key", + payload: []byte("test payload"), + privateKey: "invalid_key", + createdAt: time.Now().Unix(), + expiresAt: time.Now().Unix() + 3600, + expectErrString: "error decoding private key", + }, + { + name: "Short Private Key", + payload: []byte("test payload"), + privateKey: base64.StdEncoding.EncodeToString([]byte("short_key")), + createdAt: time.Now().Unix(), + expiresAt: time.Now().Unix() + 3600, + expectErrString: "invalid seed length", + }, + } + + for _, tt := range failureTests { + t.Run(tt.name, func(t *testing.T) { + _, err := signer.Sign(context.Background(), tt.payload, tt.privateKey, tt.createdAt, tt.expiresAt) + if err == nil { + t.Errorf("expected error but got none") + } else if !strings.Contains(err.Error(), tt.expectErrString) { + t.Errorf("expected error message to contain %q, got %v", tt.expectErrString, err) + } + if close != nil { + if err := close(); err != nil { + t.Fatalf("Cleanup function returned an error: %v", err) + } + } + }) + } +} diff --git a/pkg/plugin/implementation/signvalidator/cmd/plugin.go b/pkg/plugin/implementation/signvalidator/cmd/plugin.go new file mode 100644 index 0000000..947f956 --- /dev/null +++ b/pkg/plugin/implementation/signvalidator/cmd/plugin.go @@ -0,0 +1,24 @@ +package main + +import ( + "context" + "errors" + + "github.com/beckn/beckn-onix/pkg/plugin/definition" + "github.com/beckn/beckn-onix/pkg/plugin/implementation/signvalidator" +) + +// provider provides instances of Verifier. +type provider struct{} + +// New initializes a new Verifier instance. +func (vp provider) New(ctx context.Context, config map[string]string) (definition.SignValidator, func() error, error) { + if ctx == nil { + return nil, nil, errors.New("context cannot be nil") + } + + return signvalidator.New(ctx, &signvalidator.Config{}) +} + +// Provider is the exported symbol that the plugin manager will look for. +var Provider = provider{} diff --git a/pkg/plugin/implementation/signvalidator/cmd/plugin_test.go b/pkg/plugin/implementation/signvalidator/cmd/plugin_test.go new file mode 100644 index 0000000..a001ebf --- /dev/null +++ b/pkg/plugin/implementation/signvalidator/cmd/plugin_test.go @@ -0,0 +1,89 @@ +package main + +import ( + "context" + "testing" +) + +// TestVerifierProviderSuccess tests successful creation of a verifier. +func TestVerifierProviderSuccess(t *testing.T) { + provider := provider{} + + tests := []struct { + name string + ctx context.Context + config map[string]string + }{ + { + name: "Successful creation", + ctx: context.Background(), + config: map[string]string{}, + }, + { + name: "Nil context", + ctx: context.TODO(), + config: map[string]string{}, + }, + { + name: "Empty config", + ctx: context.Background(), + config: map[string]string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + verifier, close, err := provider.New(tt.ctx, tt.config) + + if err != nil { + t.Fatalf("Expected no error, but got: %v", err) + } + if verifier == nil { + t.Fatal("Expected verifier instance to be non-nil") + } + if close != nil { + if err := close(); err != nil { + t.Fatalf("Test %q failed: cleanup function returned an error: %v", tt.name, err) + } + } + }) + } +} + +// TestVerifierProviderFailure tests cases where verifier creation should fail. +func TestVerifierProviderFailure(t *testing.T) { + provider := provider{} + + tests := []struct { + name string + ctx context.Context + config map[string]string + wantErr bool + }{ + { + name: "Nil context failure", + ctx: nil, + config: map[string]string{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + verifierInstance, close, err := provider.New(tt.ctx, tt.config) + + if (err != nil) != tt.wantErr { + t.Fatalf("Expected error: %v, but got: %v", tt.wantErr, err) + } + if verifierInstance != nil { + t.Fatal("Expected verifier instance to be nil") + } + if close != nil { + if err := close(); err != nil { + t.Fatalf("Test %q failed: cleanup function returned an error: %v", tt.name, err) + } + } + + }) + } +} diff --git a/pkg/plugin/implementation/signvalidator/signvalidator.go b/pkg/plugin/implementation/signvalidator/signvalidator.go new file mode 100644 index 0000000..612d857 --- /dev/null +++ b/pkg/plugin/implementation/signvalidator/signvalidator.go @@ -0,0 +1,110 @@ +package signvalidator + +import ( + "context" + "crypto/ed25519" + "encoding/base64" + "fmt" + "strconv" + "strings" + "time" + + "github.com/beckn/beckn-onix/pkg/model" + "golang.org/x/crypto/blake2b" +) + +// Config struct for Verifier. +type Config struct { +} + +// validator implements the validator interface. +type validator struct { + config *Config +} + +// New creates a new Verifier instance. +func New(ctx context.Context, config *Config) (*validator, func() error, error) { + v := &validator{config: config} + + return v, nil, nil +} + +// Verify checks the signature for the given payload and public key. +func (v *validator) Validate(ctx context.Context, body []byte, header string, publicKeyBase64 string) error { + createdTimestamp, expiredTimestamp, signature, err := parseAuthHeader(header) + if err != nil { + return model.NewSignValidationErr(fmt.Errorf("error parsing header: %w", err)) + } + + signatureBytes, err := base64.StdEncoding.DecodeString(signature) + if err != nil { + return fmt.Errorf("error decoding signature: %w", err) + } + + currentTime := time.Now().Unix() + if createdTimestamp > currentTime || currentTime > expiredTimestamp { + return model.NewSignValidationErr(fmt.Errorf("signature is expired or not yet valid")) + } + + createdTime := time.Unix(createdTimestamp, 0) + expiredTime := time.Unix(expiredTimestamp, 0) + + signingString := hash(body, createdTime.Unix(), expiredTime.Unix()) + + decodedPublicKey, err := base64.StdEncoding.DecodeString(publicKeyBase64) + if err != nil { + return model.NewSignValidationErr(fmt.Errorf("error decoding public key: %w", err)) + } + + if !ed25519.Verify(ed25519.PublicKey(decodedPublicKey), []byte(signingString), signatureBytes) { + return model.NewSignValidationErr(fmt.Errorf("signature verification failed")) + } + + return nil +} + +// parseAuthHeader extracts signature values from the Authorization header. +func parseAuthHeader(header string) (int64, int64, string, error) { + header = strings.TrimPrefix(header, "Signature ") + + parts := strings.Split(header, ",") + signatureMap := make(map[string]string) + + for _, part := range parts { + keyValue := strings.SplitN(strings.TrimSpace(part), "=", 2) + if len(keyValue) == 2 { + key := strings.TrimSpace(keyValue[0]) + value := strings.Trim(keyValue[1], "\"") + signatureMap[key] = value + } + } + + createdTimestamp, err := strconv.ParseInt(signatureMap["created"], 10, 64) + if err != nil { + // TODO: Return appropriate error code when Error Code Handling Module is ready + return 0, 0, "", fmt.Errorf("invalid created timestamp: %w", err) + } + + expiredTimestamp, err := strconv.ParseInt(signatureMap["expires"], 10, 64) + if err != nil { + return 0, 0, "", model.NewSignValidationErr(fmt.Errorf("invalid expires timestamp: %w", err)) + } + + signature := signatureMap["signature"] + if signature == "" { + // TODO: Return appropriate error code when Error Code Handling Module is ready + return 0, 0, "", model.NewSignValidationErr(fmt.Errorf("signature missing in header")) + } + + return createdTimestamp, expiredTimestamp, signature, nil +} + +// hash constructs a signing string for verification. +func hash(payload []byte, createdTimestamp, expiredTimestamp int64) string { + hasher, _ := blake2b.New512(nil) + hasher.Write(payload) + hashSum := hasher.Sum(nil) + digestB64 := base64.StdEncoding.EncodeToString(hashSum) + + return fmt.Sprintf("(created): %d\n(expires): %d\ndigest: BLAKE-512=%s", createdTimestamp, expiredTimestamp, digestB64) +} diff --git a/pkg/plugin/implementation/signvalidator/signvalidator_test.go b/pkg/plugin/implementation/signvalidator/signvalidator_test.go new file mode 100644 index 0000000..160d28b --- /dev/null +++ b/pkg/plugin/implementation/signvalidator/signvalidator_test.go @@ -0,0 +1,147 @@ +package signvalidator + +import ( + "context" + "crypto/ed25519" + "encoding/base64" + "strconv" + "testing" + "time" +) + +// generateTestKeyPair generates a new ED25519 key pair for testing. +func generateTestKeyPair() (string, string) { + publicKey, privateKey, _ := ed25519.GenerateKey(nil) + return base64.StdEncoding.EncodeToString(privateKey), base64.StdEncoding.EncodeToString(publicKey) +} + +// signTestData creates a valid signature for test cases. +func signTestData(privateKeyBase64 string, body []byte, createdAt, expiresAt int64) string { + privateKeyBytes, _ := base64.StdEncoding.DecodeString(privateKeyBase64) + privateKey := ed25519.PrivateKey(privateKeyBytes) + + signingString := hash(body, createdAt, expiresAt) + signature := ed25519.Sign(privateKey, []byte(signingString)) + + return base64.StdEncoding.EncodeToString(signature) +} + +// TestVerifySuccessCases tests all valid signature verification cases. +func TestVerifySuccess(t *testing.T) { + privateKeyBase64, publicKeyBase64 := generateTestKeyPair() + + tests := []struct { + name string + body []byte + createdAt int64 + expiresAt int64 + }{ + { + name: "Valid Signature", + body: []byte("Test Payload"), + createdAt: time.Now().Unix(), + expiresAt: time.Now().Unix() + 3600, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + signature := signTestData(privateKeyBase64, tt.body, tt.createdAt, tt.expiresAt) + header := `Signature created="` + strconv.FormatInt(tt.createdAt, 10) + + `", expires="` + strconv.FormatInt(tt.expiresAt, 10) + + `", signature="` + signature + `"` + + verifier, close, _ := New(context.Background(), &Config{}) + err := verifier.Validate(context.Background(), tt.body, header, publicKeyBase64) + + if err != nil { + t.Fatalf("Expected no error, but got: %v", err) + } + if close != nil { + if err := close(); err != nil { + t.Fatalf("Test %q failed: cleanup function returned an error: %v", tt.name, err) + } + } + }) + } +} + +// TestVerifyFailureCases tests all invalid signature verification cases. +func TestVerifyFailure(t *testing.T) { + privateKeyBase64, publicKeyBase64 := generateTestKeyPair() + _, wrongPublicKeyBase64 := generateTestKeyPair() + + tests := []struct { + name string + body []byte + header string + pubKey string + }{ + { + name: "Missing Authorization Header", + body: []byte("Test Payload"), + header: "", + pubKey: publicKeyBase64, + }, + { + name: "Malformed Header", + body: []byte("Test Payload"), + header: `InvalidSignature created="wrong"`, + pubKey: publicKeyBase64, + }, + { + name: "Invalid Base64 Signature", + body: []byte("Test Payload"), + header: `Signature created="` + strconv.FormatInt(time.Now().Unix(), 10) + + `", expires="` + strconv.FormatInt(time.Now().Unix()+3600, 10) + + `", signature="!!INVALIDBASE64!!"`, + pubKey: publicKeyBase64, + }, + { + name: "Expired Signature", + body: []byte("Test Payload"), + header: `Signature created="` + strconv.FormatInt(time.Now().Unix()-7200, 10) + + `", expires="` + strconv.FormatInt(time.Now().Unix()-3600, 10) + + `", signature="` + signTestData(privateKeyBase64, []byte("Test Payload"), time.Now().Unix()-7200, time.Now().Unix()-3600) + `"`, + pubKey: publicKeyBase64, + }, + { + name: "Invalid Public Key", + body: []byte("Test Payload"), + header: `Signature created="` + strconv.FormatInt(time.Now().Unix(), 10) + + `", expires="` + strconv.FormatInt(time.Now().Unix()+3600, 10) + + `", signature="` + signTestData(privateKeyBase64, []byte("Test Payload"), time.Now().Unix(), time.Now().Unix()+3600) + `"`, + pubKey: wrongPublicKeyBase64, + }, + { + name: "Invalid Expires Timestamp", + body: []byte("Test Payload"), + header: `Signature created="` + strconv.FormatInt(time.Now().Unix(), 10) + + `", expires="invalid_timestamp"`, + pubKey: publicKeyBase64, + }, + { + name: "Signature Missing in Headers", + body: []byte("Test Payload"), + header: `Signature created="` + strconv.FormatInt(time.Now().Unix(), 10) + + `", expires="` + strconv.FormatInt(time.Now().Unix()+3600, 10) + `"`, + pubKey: publicKeyBase64, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + verifier, close, _ := New(context.Background(), &Config{}) + err := verifier.Validate(context.Background(), tt.body, tt.header, tt.pubKey) + + if err == nil { + t.Fatal("Expected an error but got none") + } + if close != nil { + if err := close(); err != nil { + t.Fatalf("Test %q failed: cleanup function returned an error: %v", tt.name, err) + } + } + }) + } +} diff --git a/pkg/plugin/manager.go b/pkg/plugin/manager.go index 2cc76bb..c776b51 100644 --- a/pkg/plugin/manager.go +++ b/pkg/plugin/manager.go @@ -1,12 +1,22 @@ package plugin import ( +<<<<<<< HEAD "context" "fmt" +======= + "archive/zip" + "context" + "fmt" + "io" + "io/fs" + "net/http" +>>>>>>> fdec61e90d57d3d82345d023c1a0d33d5a90583b "os" "path/filepath" "plugin" "strings" +<<<<<<< HEAD "github.com/beckn/beckn-onix/pkg/plugin/definition" @@ -142,4 +152,381 @@ func LoadConfig(path string) (*Config, error) { } return &cfg, nil +======= + "time" + + "github.com/beckn/beckn-onix/pkg/log" + "github.com/beckn/beckn-onix/pkg/plugin/definition" +) + +type onixPlugin interface { + Lookup(string) (plugin.Symbol, error) +} + +// Manager is responsible for managing dynamically loaded plugins. +type Manager struct { + plugins map[string]onixPlugin // plugins holds the dynamically loaded plugins. + closers []func() // closers contains functions to release resources when the manager is closed. +} + +func validateMgrCfg(cfg *ManagerConfig) error { + if cfg.Root == "" { + return fmt.Errorf("root path cannot be empty") + } + return nil +} + +// NewManager initializes a new Manager instance by loading plugins from the specified configuration. +func NewManager(ctx context.Context, cfg *ManagerConfig) (*Manager, func(), error) { + if err := validateMgrCfg(cfg); err != nil { + return nil, nil, fmt.Errorf("Invalid config: %w", err) + } + log.Debugf(ctx, "RemoteRoot : %s", cfg.RemoteRoot) + if len(cfg.RemoteRoot) != 0 { + log.Debugf(ctx, "Unzipping files from : %s to : %s", cfg.RemoteRoot, cfg.Root) + if err := unzip(cfg.RemoteRoot, cfg.Root); err != nil { + return nil, nil, err + } + } + plugins, err := plugins(ctx, cfg) + if err != nil { + return nil, nil, err + } + + closers := []func(){} + return &Manager{plugins: plugins, closers: closers}, func() { + for _, closer := range closers { + closer() + } + }, nil +} + +func plugins(ctx context.Context, cfg *ManagerConfig) (map[string]onixPlugin, error) { + plugins := make(map[string]onixPlugin) + + err := filepath.WalkDir(cfg.Root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + return nil // Skip directories + } + + if strings.HasSuffix(d.Name(), ".so") { + id := strings.TrimSuffix(d.Name(), ".so") // Extract plugin ID + p, elapsed, err := loadPlugin(ctx, path, id) + if err != nil { + return err + } + plugins[id] = p + log.Debugf(ctx, "Loaded plugin: %s in %s", id, elapsed) + } + return nil + }) + + if err != nil { + return nil, err + } + + return plugins, nil +} + +// loadPlugin attempts to load a plugin from the given path and logs the execution time. +func loadPlugin(ctx context.Context, path, id string) (onixPlugin, time.Duration, error) { + log.Debugf(ctx, "Loading plugin: %s", id) + start := time.Now() + + p, err := plugin.Open(path) + if err != nil { + return nil, 0, fmt.Errorf("failed to open plugin %s: %w", id, err) + } + + elapsed := time.Since(start) + return p, elapsed, nil +} + +func provider[T any](plugins map[string]onixPlugin, id string) (T, error) { + var zero T + pgn, ok := plugins[id] + if !ok { + return zero, fmt.Errorf("plugin %s not found", id) + } + provider, err := pgn.Lookup("Provider") + if err != nil { + return zero, fmt.Errorf("failed to lookup Provider for %s: %w", id, err) + } + log.Debugf(context.Background(), "Provider type: %T\n", provider) + + pp, ok := provider.(T) + if !ok { + return zero, fmt.Errorf("failed to cast Provider for %s", id) + } + log.Debugf(context.Background(), "Casting successful for: %s", provider) + return pp, nil +} + +// Publisher returns a Publisher instance based on the provided configuration. +// It reuses the loaded provider and registers a cleanup function. +func (m *Manager) Publisher(ctx context.Context, cfg *Config) (definition.Publisher, error) { + pp, err := provider[definition.PublisherProvider](m.plugins, cfg.ID) + if err != nil { + return nil, fmt.Errorf("failed to load provider for %s: %w", cfg.ID, err) + } + p, closer, err := pp.New(ctx, cfg.Config) + if err != nil { + return nil, err + } + if closer != nil { + m.closers = append(m.closers, func() { + if err := closer(); err != nil { + panic(err) + } + }) + } + return p, nil +} + +// SchemaValidator returns a SchemaValidator instance based on the provided configuration. +// It registers a cleanup function for resource management. +func (m *Manager) SchemaValidator(ctx context.Context, cfg *Config) (definition.SchemaValidator, error) { + vp, err := provider[definition.SchemaValidatorProvider](m.plugins, cfg.ID) + if err != nil { + return nil, fmt.Errorf("failed to load provider for %s: %w", cfg.ID, err) + } + v, closer, err := vp.New(ctx, cfg.Config) + if err != nil { + return nil, err + } + if closer != nil { + m.closers = append(m.closers, func() { + if err := closer(); err != nil { + panic(err) + } + }) + } + return v, nil +} + +// Router returns a Router instance based on the provided configuration. +// It registers a cleanup function for resource management. +func (m *Manager) Router(ctx context.Context, cfg *Config) (definition.Router, error) { + rp, err := provider[definition.RouterProvider](m.plugins, cfg.ID) + if err != nil { + return nil, fmt.Errorf("failed to load provider for %s: %w", cfg.ID, err) + } + router, closer, err := rp.New(ctx, cfg.Config) + if err != nil { + return nil, err + } + if closer != nil { + m.closers = append(m.closers, func() { + if err := closer(); err != nil { + panic(err) + } + }) + } + return router, nil +} + +// Middleware returns an HTTP middleware function based on the provided configuration. +func (m *Manager) Middleware(ctx context.Context, cfg *Config) (func(http.Handler) http.Handler, error) { + mwp, err := provider[definition.MiddlewareProvider](m.plugins, cfg.ID) + if err != nil { + return nil, fmt.Errorf("failed to load provider for %s: %w", cfg.ID, err) + } + return mwp.New(ctx, cfg.Config) +} + +// Step returns a Step instance based on the provided configuration. +func (m *Manager) Step(ctx context.Context, cfg *Config) (definition.Step, error) { + sp, err := provider[definition.StepProvider](m.plugins, cfg.ID) + if err != nil { + return nil, fmt.Errorf("failed to load provider for %s: %w", cfg.ID, err) + } + step, closer, error := sp.New(ctx, cfg.Config) + if closer != nil { + m.closers = append(m.closers, closer) + } + return step, error +} + +// Cache returns a Cache instance based on the provided configuration. +// It registers a cleanup function for resource management. +func (m *Manager) Cache(ctx context.Context, cfg *Config) (definition.Cache, error) { + cp, err := provider[definition.CacheProvider](m.plugins, cfg.ID) + if err != nil { + return nil, fmt.Errorf("failed to load provider for %s: %w", cfg.ID, err) + } + c, closer, err := cp.New(ctx, cfg.Config) + if err != nil { + return nil, err + } + if closer != nil { + m.closers = append(m.closers, func() { + if err := closer(); err != nil { + panic(err) + } + }) + } + return c, nil +} + +// Signer returns a Signer instance based on the provided configuration. +// It registers a cleanup function for resource management. +func (m *Manager) Signer(ctx context.Context, cfg *Config) (definition.Signer, error) { + sp, err := provider[definition.SignerProvider](m.plugins, cfg.ID) + if err != nil { + return nil, fmt.Errorf("failed to load provider for %s: %w", cfg.ID, err) + } + s, closer, err := sp.New(ctx, cfg.Config) + if err != nil { + return nil, err + } + if closer != nil { + m.closers = append(m.closers, func() { + if err := closer(); err != nil { + panic(err) + } + }) + } + return s, nil +} + +// Encryptor returns an Encrypter instance based on the provided configuration. +// It registers a cleanup function for resource management. +func (m *Manager) Encryptor(ctx context.Context, cfg *Config) (definition.Encrypter, error) { + ep, err := provider[definition.EncrypterProvider](m.plugins, cfg.ID) + if err != nil { + return nil, fmt.Errorf("failed to load provider for %s: %w", cfg.ID, err) + } + encrypter, closer, err := ep.New(ctx, cfg.Config) + if err != nil { + return nil, err + } + if closer != nil { + m.closers = append(m.closers, func() { + if err := closer(); err != nil { + panic(err) + } + }) + } + return encrypter, nil +} + +// Decryptor returns a Decrypter instance based on the provided configuration. +// It registers a cleanup function for resource management. +func (m *Manager) Decryptor(ctx context.Context, cfg *Config) (definition.Decrypter, error) { + dp, err := provider[definition.DecrypterProvider](m.plugins, cfg.ID) + if err != nil { + return nil, fmt.Errorf("failed to load provider for %s: %w", cfg.ID, err) + } + + decrypter, closer, err := dp.New(ctx, cfg.Config) + if err != nil { + return nil, err + } + + if closer != nil { + m.closers = append(m.closers, func() { + if err := closer(); err != nil { + panic(err) + } + }) + } + + return decrypter, nil +} + +// SignValidator returns a SignValidator instance based on the provided configuration. +// It registers a cleanup function for resource management. +func (m *Manager) SignValidator(ctx context.Context, cfg *Config) (definition.SignValidator, error) { + svp, err := provider[definition.SignValidatorProvider](m.plugins, cfg.ID) + if err != nil { + return nil, fmt.Errorf("failed to load provider for %s: %w", cfg.ID, err) + } + v, closer, err := svp.New(ctx, cfg.Config) + if err != nil { + return nil, err + } + if closer != nil { + m.closers = append(m.closers, func() { + if err := closer(); err != nil { + panic(err) + } + }) + } + return v, nil +} + +// KeyManager returns a KeyManager instance based on the provided configuration. +// It reuses the loaded provider. +func (m *Manager) KeyManager(ctx context.Context, cache definition.Cache, rClient definition.RegistryLookup, cfg *Config) (definition.KeyManager, error) { + + kmp, err := provider[definition.KeyManagerProvider](m.plugins, cfg.ID) + if err != nil { + return nil, fmt.Errorf("failed to load provider for %s: %w", cfg.ID, err) + } + km, closer, err := kmp.New(ctx, cache, rClient, cfg.Config) + if err != nil { + return nil, err + } + if closer != nil { + m.closers = append(m.closers, func() { + if err := closer(); err != nil { + panic(err) + } + }) + } + return km, nil +} + +// Validator implements handler.PluginManager. +func (m *Manager) Validator(ctx context.Context, cfg *Config) (definition.SchemaValidator, error) { + panic("unimplemented") +} + +// Unzip extracts a ZIP file to the specified destination +func unzip(src, dest string) error { + r, err := zip.OpenReader(src) + if err != nil { + return err + } + defer r.Close() + + // Ensure the destination directory exists + if err := os.MkdirAll(dest, 0755); err != nil { + return err + } + + for _, f := range r.File { + + fpath := filepath.Join(dest, f.Name) + // Ensure directory exists + log.Debugf(context.Background(), "Pain : fpath: %s,filepath.Dir(fpath): %s", fpath, filepath.Dir(fpath)) + if err := os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil { + return err + } + // Open the file inside the zip + srcFile, err := f.Open() + if err != nil { + return err + } + defer srcFile.Close() + + // Create the destination file + dstFile, err := os.Create(fpath) + if err != nil { + return err + } + defer dstFile.Close() + + // Copy file contents + if _, err := io.Copy(dstFile, srcFile); err != nil { + return err + } + } + + return nil +>>>>>>> fdec61e90d57d3d82345d023c1a0d33d5a90583b } diff --git a/pkg/plugin/manager_test.go b/pkg/plugin/manager_test.go new file mode 100644 index 0000000..29d029b --- /dev/null +++ b/pkg/plugin/manager_test.go @@ -0,0 +1,2495 @@ +package plugin + +import ( + "archive/zip" + "context" + "errors" + "net/http" + "os" + "os/exec" + "path/filepath" + "plugin" + "strings" + "testing" + + "github.com/beckn/beckn-onix/pkg/model" + "github.com/beckn/beckn-onix/pkg/plugin/definition" +) + +type mockPlugin struct { + symbol plugin.Symbol + err error +} + +func (m *mockPlugin) Lookup(str string) (plugin.Symbol, error) { + return m.symbol, m.err +} + +// Mock implementations for testing. +type mockPublisher struct { + definition.Publisher +} + +type mockSchemaValidator struct { + definition.SchemaValidator +} + +type mockRouter struct { + definition.Router +} + +type mockStep struct { + definition.Step +} + +type mockCache struct { + definition.Cache +} + +type mockSigner struct { + definition.Signer +} + +type mockEncrypter struct { + definition.Encrypter +} + +type mockDecrypter struct { + definition.Decrypter +} + +type mockSignValidator struct { + definition.SignValidator +} + +type mockKeyManager struct { + definition.KeyManager + err error +} + +func (m *mockKeyManager) GenerateKeyPairs() (*model.Keyset, error) { + if m.err != nil { + return nil, m.err + } + return &model.Keyset{}, nil +} + +func (m *mockKeyManager) StorePrivateKeys(ctx context.Context, keyID string, keys *model.Keyset) error { + return m.err +} + +func (m *mockKeyManager) SigningPrivateKey(ctx context.Context, keyID string) (string, string, error) { + if m.err != nil { + return "", "", m.err + } + return "signing-key", "signing-algo", nil +} + +func (m *mockKeyManager) EncrPrivateKey(ctx context.Context, keyID string) (string, string, error) { + if m.err != nil { + return "", "", m.err + } + return "encr-key", "encr-algo", nil +} + +func (m *mockKeyManager) SigningPublicKey(ctx context.Context, subscriberID, uniqueKeyID string) (string, error) { + if m.err != nil { + return "", m.err + } + return "public-signing-key", nil +} + +func (m *mockKeyManager) EncrPublicKey(ctx context.Context, subscriberID, uniqueKeyID string) (string, error) { + if m.err != nil { + return "", m.err + } + return "public-encr-key", nil +} + +func (m *mockKeyManager) DeletePrivateKeys(ctx context.Context, keyID string) error { + return m.err +} + +// Mock providers. +type mockPublisherProvider struct { + publisher definition.Publisher + err error + errFunc func() error +} + +func (m *mockPublisherProvider) New(ctx context.Context, config map[string]string) (definition.Publisher, func() error, error) { + return m.publisher, m.errFunc, m.err +} + +type mockSchemaValidatorProvider struct { + validator *mockSchemaValidator + err error + errFunc func() error +} + +func (m *mockSchemaValidatorProvider) New(ctx context.Context, config map[string]string) (definition.SchemaValidator, func() error, error) { + if m.err != nil { + return nil, nil, m.err + } + return m.validator, func() error { return nil }, nil +} + +// Mock providers for additional interfaces. +type mockRouterProvider struct { + router *mockRouter + err error + errFunc func() error +} + +func (m *mockRouterProvider) New(ctx context.Context, config map[string]string) (definition.Router, func() error, error) { + if m.err != nil { + return nil, nil, m.err + } + return m.router, func() error { return nil }, nil +} + +type mockMiddlewareProvider struct { + middleware func(http.Handler) http.Handler + err error +} + +func (m *mockMiddlewareProvider) New(ctx context.Context, config map[string]string) (func(http.Handler) http.Handler, error) { + if m.err != nil { + return nil, m.err + } + if m.middleware == nil { + m.middleware = func(h http.Handler) http.Handler { return h } + } + return m.middleware, nil +} + +type mockStepProvider struct { + step *mockStep + err error + errFunc func() error +} + +func (m *mockStepProvider) New(ctx context.Context, config map[string]string) (definition.Step, func(), error) { + if m.err != nil { + return nil, nil, m.err + } + return m.step, func() {}, nil +} + +// Mock providers for additional interfaces. +type mockCacheProvider struct { + cache *mockCache + err error + errFunc func() error +} + +func (m *mockCacheProvider) New(ctx context.Context, config map[string]string) (definition.Cache, func() error, error) { + if m.err != nil { + return nil, nil, m.err + } + return m.cache, func() error { return nil }, nil +} + +type mockSignerProvider struct { + signer *mockSigner + err error + errFunc func() error +} + +func (m *mockSignerProvider) New(ctx context.Context, config map[string]string) (definition.Signer, func() error, error) { + if m.err != nil { + return nil, nil, m.err + } + return m.signer, func() error { return nil }, nil +} + +type mockEncrypterProvider struct { + encrypter *mockEncrypter + err error + errFunc func() error +} + +func (m *mockEncrypterProvider) New(ctx context.Context, config map[string]string) (definition.Encrypter, func() error, error) { + if m.err != nil { + return nil, nil, m.err + } + return m.encrypter, func() error { return nil }, nil +} + +type mockDecrypterProvider struct { + decrypter *mockDecrypter + err error + errFunc func() error +} + +func (m *mockDecrypterProvider) New(ctx context.Context, config map[string]string) (definition.Decrypter, func() error, error) { + if m.err != nil { + return nil, nil, m.err + } + return m.decrypter, func() error { return nil }, nil +} + +type mockSignValidatorProvider struct { + validator *mockSignValidator + err error + errFunc func() error +} + +func (m *mockSignValidatorProvider) New(ctx context.Context, config map[string]string) (definition.SignValidator, func() error, error) { + if m.err != nil { + return nil, nil, m.err + } + return m.validator, func() error { return nil }, nil +} + +type mockKeyManagerProvider struct { + keyManager *mockKeyManager + err error + errFunc func() error +} + +func (m *mockKeyManagerProvider) New(ctx context.Context, cache definition.Cache, lookup definition.RegistryLookup, config map[string]string) (definition.KeyManager, func() error, error) { + if m.err != nil { + return nil, nil, m.err + } + return m.keyManager, func() error { return nil }, nil +} + +// Mock registry lookup for testing. +type mockRegistryLookup struct { + definition.RegistryLookup +} + +// createTestZip creates a zip file with test content in a temporary directory. +func createTestZip(t *testing.T) string { + t.Helper() + // Create a temporary directory for the zip file + tempDir := t.TempDir() + zipPath := filepath.Join(tempDir, "test.zip") + + // Create a zip file + zipFile, err := os.Create(zipPath) + if err != nil { + t.Fatalf("Failed to create zip file: %v", err) + } + defer zipFile.Close() + + zipWriter := zip.NewWriter(zipFile) + defer zipWriter.Close() + + // Add a test file to the zip + testFile, err := zipWriter.Create("test.txt") + if err != nil { + t.Fatalf("Failed to create file in zip: %v", err) + } + _, err = testFile.Write([]byte("test content")) + if err != nil { + t.Fatalf("Failed to write to file: %v", err) + } + + return zipPath +} + +// TestNewManagerSuccess tests the successful scenarios of the NewManager function. +func TestNewManagerSuccess(t *testing.T) { + // Build the dummy plugin first. + cmd := exec.Command("go", "build", "-buildmode=plugin", "-o", "./testdata/dummy.so", "./testdata/dummy.go") + if err := cmd.Run(); err != nil { + t.Fatalf("Failed to build dummy plugin: %v", err) + } + + // Clean up the .so file after test completes. + t.Cleanup(func() { + if err := os.Remove("./testdata/dummy.so"); err != nil && !os.IsNotExist(err) { + t.Logf("Failed to remove dummy.so: %v", err) + } + }) + + tests := []struct { + name string + cfg *ManagerConfig + }{ + { + name: "valid config with root path", + cfg: &ManagerConfig{ + Root: t.TempDir(), + RemoteRoot: "", + }, + }, + { + name: "valid config with remote root", + cfg: &ManagerConfig{ + Root: t.TempDir(), + RemoteRoot: func() string { + zipPath := createTestZip(t) + return zipPath + }(), + }, + }, + { + name: "valid config with so file", + cfg: &ManagerConfig{ + Root: "./testdata", + RemoteRoot: "", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + m, cleanup, err := NewManager(ctx, tt.cfg) + if err != nil { + t.Fatalf("NewManager() error = %v, want nil", err) + } + if m == nil { + t.Fatal("NewManager() returned nil manager") + } + if cleanup == nil { + t.Fatal("NewManager() returned nil cleanup function") + } + + // Verify manager fields. + if m.plugins == nil { + t.Fatal("NewManager() returned manager with nil plugins map") + } + if m.closers == nil { + t.Fatal("NewManager() returned manager with nil closers slice") + } + + // Call cleanup to ensure it doesn't panic. + cleanup() + }) + } +} + +// TestNewManagerFailure tests the failure scenarios of the NewManager function. +func TestNewManagerFailure(t *testing.T) { + tests := []struct { + name string + cfg *ManagerConfig + expectedError string + }{ + { + name: "invalid config with empty root", + cfg: &ManagerConfig{ + Root: "", + RemoteRoot: "", + }, + expectedError: "root path cannot be empty", + }, + { + name: "invalid config with nonexistent root", + cfg: &ManagerConfig{ + Root: "/nonexistent/dir", + RemoteRoot: "", + }, + expectedError: "no such file or directory", + }, + { + name: "invalid config with nonexistent remote root", + cfg: &ManagerConfig{ + Root: t.TempDir(), + RemoteRoot: "/nonexistent/remote.zip", + }, + expectedError: "no such file or directory", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + m, cleanup, err := NewManager(ctx, tt.cfg) + if err == nil { + t.Fatal("NewManager() expected error, got nil") + } + if m != nil { + t.Fatal("NewManager() returned non-nil manager for error case") + } + if !strings.Contains(err.Error(), tt.expectedError) { + t.Fatalf("NewManager() error = %v, want error containing %q", err, tt.expectedError) + } + if cleanup != nil { + t.Fatal("NewManager() returned non-nil cleanup function for error case") + } + + }) + } +} + +func TestPublisherSuccess(t *testing.T) { + t.Run("successful publisher creation", func(t *testing.T) { + publisherID := "publisherId" + mockPublisher := &mockPublisher{} + errFunc := func() error { return nil } + m := &Manager{ + plugins: map[string]onixPlugin{ + publisherID: &mockPlugin{ + symbol: &mockPublisherProvider{ + publisher: mockPublisher, + errFunc: errFunc, + }, + }, + }, + closers: []func(){}, + } + + p, err := m.Publisher(context.Background(), &Config{ + ID: publisherID, + Config: map[string]string{}, + }) + + if err != nil { + t.Fatalf("Manager.Publisher() error = %v, want no error", err) + } + + if p != mockPublisher { + t.Fatalf("Manager.Publisher() did not return the correct publisher") + } + + if len(m.closers) != 1 { + t.Fatalf("Manager.closers has %d closers, expected 1", len(m.closers)) + } + + m.closers[0]() + + }) +} + +// TestPublisherFailure tests the failure scenarios of the Publisher method. +func TestPublisherFailure(t *testing.T) { + tests := []struct { + name string + publisherID string + plugins map[string]onixPlugin + expectedError string + }{ + { + name: "plugin not found", + publisherID: "nonexistent", + plugins: make(map[string]onixPlugin), + expectedError: "plugin nonexistent not found", + }, + { + name: "provider error", + publisherID: "error-provider", + plugins: map[string]onixPlugin{ + "error-provider": &mockPlugin{ + symbol: nil, + err: errors.New("provider error"), + }, + }, + expectedError: "provider error", + }, + { + name: "lookup error", + publisherID: "lookup-error", + plugins: map[string]onixPlugin{ + "lookup-error": &mockPlugin{ + symbol: nil, + err: errors.New("lookup failed"), + }, + }, + expectedError: "lookup failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := &Manager{ + closers: []func(){}, + plugins: tt.plugins, + } + + p, err := m.Publisher(context.Background(), &Config{ + ID: tt.publisherID, + Config: map[string]string{}, + }) + + if err == nil { + t.Fatal("Manager.Publisher() expected error, got nil") + } else if !strings.Contains(err.Error(), tt.expectedError) { + t.Fatalf("Manager.Publisher() error = %v, want error containing %q", err, tt.expectedError) + } + + if p != nil { + t.Fatal("Manager.Publisher() expected nil publisher, got non-nil") + } + }) + } +} + +// TestSchemaValidatorSuccess tests the successful scenarios of the SchemaValidator method. +func TestSchemaValidatorSuccess(t *testing.T) { + tests := []struct { + name string + cfg *Config + plugin *mockSchemaValidatorProvider + }{ + { + name: "successful validator creation", + cfg: &Config{ + ID: "test-validator", + Config: map[string]string{}, + }, + plugin: &mockSchemaValidatorProvider{ + validator: &mockSchemaValidator{}, + errFunc: func() error { return nil }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a manager with the mock plugin. + m := &Manager{ + plugins: map[string]onixPlugin{ + tt.cfg.ID: &mockPlugin{ + symbol: tt.plugin, + }, + }, + closers: []func(){}, + } + + // Call SchemaValidator. + validator, err := m.SchemaValidator(context.Background(), tt.cfg) + + // Check success case. + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if validator != tt.plugin.validator { + t.Fatal("validator does not match expected instance") + } + + if len(m.closers) != 1 { + t.Fatalf("Manager.closers has %d closers, expected 1", len(m.closers)) + } + + m.closers[0]() + }) + } +} + +// TestSchemaValidatorFailure tests the failure scenarios of the SchemaValidator method. +func TestSchemaValidatorFailure(t *testing.T) { + tests := []struct { + name string + cfg *Config + plugin definition.SchemaValidatorProvider + expectedError string + }{ + { + name: "provider error", + cfg: &Config{ + ID: "test-validator", + Config: map[string]string{}, + }, + plugin: &mockSchemaValidatorProvider{ + err: errors.New("provider error"), + }, + expectedError: "provider error", + }, + { + name: "plugin not found", + cfg: &Config{ + ID: "nonexistent-validator", + Config: map[string]string{}, + }, + plugin: nil, + expectedError: "plugin nonexistent-validator not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a manager with the mock plugin. + m := &Manager{ + plugins: make(map[string]onixPlugin), + closers: []func(){}, + } + + // Only add the plugin if it's not nil. + if tt.plugin != nil { + m.plugins[tt.cfg.ID] = &mockPlugin{ + symbol: tt.plugin, + } + } + + // Call SchemaValidator. + validator, err := m.SchemaValidator(context.Background(), tt.cfg) + + // Check error. + if err == nil { + t.Fatal("expected error, got nil") + } else if !strings.Contains(err.Error(), tt.expectedError) { + t.Fatalf("error = %v, want error containing %q", err, tt.expectedError) + } + if validator != nil { + t.Fatal("expected nil validator, got non-nil") + } + }) + } +} + +// TestRouterSuccess tests the successful scenarios of the Router method. +func TestRouterSuccess(t *testing.T) { + t.Run("successful router creation", func(t *testing.T) { + cfg := &Config{ + ID: "test-router", + Config: map[string]string{}, + } + plugin := &mockRouterProvider{ + router: &mockRouter{}, + errFunc: func() error { return nil }, + } + // Create a manager with the mock plugin. + m := &Manager{ + plugins: map[string]onixPlugin{ + cfg.ID: &mockPlugin{ + symbol: plugin, + }, + }, + closers: []func(){}, + } + + // Call Router. + router, err := m.Router(context.Background(), cfg) + + // Check success case. + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if router == nil { + t.Fatal("expected non-nil router, got nil") + } + if router != plugin.router { + t.Fatal("router does not match expected instance") + } + + if len(m.closers) != 1 { + t.Fatalf("Manager.closers has %d closers, expected 1", len(m.closers)) + } + + m.closers[0]() + }) +} + +// TestRouterFailure tests the failure scenarios of the Router method. +func TestRouterFailure(t *testing.T) { + tests := []struct { + name string + cfg *Config + plugin *mockRouterProvider + expectedError string + }{ + { + name: "provider error", + cfg: &Config{ + ID: "test-router", + Config: map[string]string{}, + }, + plugin: &mockRouterProvider{ + err: errors.New("provider error"), + }, + expectedError: "provider error", + }, + { + name: "plugin not found", + cfg: &Config{ + ID: "nonexistent-router", + Config: map[string]string{}, + }, + plugin: nil, + expectedError: "plugin nonexistent-router not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a manager with the mock plugin. + m := &Manager{ + plugins: make(map[string]onixPlugin), + closers: []func(){}, + } + + // Only add the plugin if it's not nil. + if tt.plugin != nil { + m.plugins[tt.cfg.ID] = &mockPlugin{ + symbol: tt.plugin, + } + } + + // Call Router. + router, err := m.Router(context.Background(), tt.cfg) + + // Check error. + if err == nil { + t.Fatal("expected error, got nil") + } else if !strings.Contains(err.Error(), tt.expectedError) { + t.Fatalf("error = %v, want error containing %q", err, tt.expectedError) + } + if router != nil { + t.Fatal("expected nil router, got non-nil") + } + }) + } +} + +// TestStepSuccess tests the successful scenarios of the Step method. +func TestStepSuccess(t *testing.T) { + tests := []struct { + name string + cfg *Config + plugin *mockStepProvider + }{ + { + name: "successful step creation", + cfg: &Config{ + ID: "test-step", + Config: map[string]string{}, + }, + plugin: &mockStepProvider{ + step: &mockStep{}, + errFunc: func() error { return nil }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a manager with the mock plugin. + m := &Manager{ + plugins: map[string]onixPlugin{ + tt.cfg.ID: &mockPlugin{ + symbol: tt.plugin, + }, + }, + closers: []func(){}, + } + + // Call Step. + step, err := m.Step(context.Background(), tt.cfg) + + // Check success case. + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if step == nil { + t.Fatal("expected non-nil step, got nil") + } + if step != tt.plugin.step { + t.Fatal("step does not match expected instance") + } + + if len(m.closers) != 1 { + t.Fatalf("Manager.closers has %d closers, expected 1", len(m.closers)) + } + + m.closers[0]() + }) + } +} + +// TestStepFailure tests the failure scenarios of the Step method. +func TestStepFailure(t *testing.T) { + tests := []struct { + name string + cfg *Config + plugin *mockStepProvider + expectedError string + }{ + { + name: "provider error", + cfg: &Config{ + ID: "test-step", + Config: map[string]string{}, + }, + plugin: &mockStepProvider{ + err: errors.New("provider error"), + }, + expectedError: "provider error", + }, + { + name: "plugin not found", + cfg: &Config{ + ID: "nonexistent-step", + Config: map[string]string{}, + }, + plugin: nil, + expectedError: "plugin nonexistent-step not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a manager with the mock plugin. + m := &Manager{ + plugins: make(map[string]onixPlugin), + closers: []func(){}, + } + + // Only add the plugin if it's not nil. + if tt.plugin != nil { + m.plugins[tt.cfg.ID] = &mockPlugin{ + symbol: tt.plugin, + } + } + + // Call Step. + step, err := m.Step(context.Background(), tt.cfg) + + // Check error. + if err == nil { + t.Fatal("expected error, got nil") + } else if !strings.Contains(err.Error(), tt.expectedError) { + t.Fatalf("error = %v, want error containing %q", err, tt.expectedError) + } + if step != nil { + t.Fatal("expected nil step, got non-nil") + } + }) + } +} + +// TestCacheSuccess tests the successful scenarios of the Cache method. +func TestCacheSuccess(t *testing.T) { + tests := []struct { + name string + cfg *Config + plugin *mockCacheProvider + }{ + { + name: "successful cache creation", + cfg: &Config{ + ID: "test-cache", + Config: map[string]string{}, + }, + plugin: &mockCacheProvider{ + cache: &mockCache{}, + errFunc: func() error { return nil }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a manager with the mock plugin. + m := &Manager{ + plugins: map[string]onixPlugin{ + tt.cfg.ID: &mockPlugin{ + symbol: tt.plugin, + }, + }, + closers: []func(){}, + } + + // Call Cache. + cache, err := m.Cache(context.Background(), tt.cfg) + + // Check success case. + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cache == nil { + t.Fatal("expected non-nil cache, got nil") + } + + if cache != tt.plugin.cache { + t.Fatal("cache does not match expected instance") + } + + if len(m.closers) != 1 { + t.Fatalf("Manager.closers has %d closers, expected 1", len(m.closers)) + } + + m.closers[0]() + }) + } +} + +// TestCacheFailure tests the failure scenarios of the Cache method. +func TestCacheFailure(t *testing.T) { + tests := []struct { + name string + cfg *Config + plugin *mockCacheProvider + expectedError string + }{ + { + name: "provider error", + cfg: &Config{ + ID: "test-cache", + Config: map[string]string{}, + }, + plugin: &mockCacheProvider{ + err: errors.New("provider error"), + }, + expectedError: "provider error", + }, + { + name: "plugin not found", + cfg: &Config{ + ID: "nonexistent-cache", + Config: map[string]string{}, + }, + plugin: nil, + expectedError: "plugin nonexistent-cache not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a manager with the mock plugin. + m := &Manager{ + plugins: make(map[string]onixPlugin), + closers: []func(){}, + } + + // Only add the plugin if it's not nil. + if tt.plugin != nil { + m.plugins[tt.cfg.ID] = &mockPlugin{ + symbol: tt.plugin, + } + } + + // Call Cache. + cache, err := m.Cache(context.Background(), tt.cfg) + + // Check error. + if err == nil { + t.Fatal("expected error, got nil") + } else if !strings.Contains(err.Error(), tt.expectedError) { + t.Fatalf("error = %v, want error containing %q", err, tt.expectedError) + } + if cache != nil { + t.Fatal("expected nil cache, got non-nil") + } + }) + } +} + +// TestSignerSuccess tests the successful scenarios of the Signer method. +func TestSignerSuccess(t *testing.T) { + tests := []struct { + name string + cfg *Config + plugin *mockSignerProvider + }{ + { + name: "successful signer creation", + cfg: &Config{ + ID: "test-signer", + Config: map[string]string{}, + }, + plugin: &mockSignerProvider{ + signer: &mockSigner{}, + errFunc: func() error { return nil }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a manager with the mock plugin. + m := &Manager{ + plugins: map[string]onixPlugin{ + tt.cfg.ID: &mockPlugin{ + symbol: tt.plugin, + }, + }, + closers: []func(){}, + } + + // Call Signer. + signer, err := m.Signer(context.Background(), tt.cfg) + + // Check success case. + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if signer == nil { + t.Fatal("expected non-nil signer, got nil") + } + + if signer != tt.plugin.signer { + t.Fatal("signer does not match expected instance") + } + + if len(m.closers) != 1 { + t.Fatalf("Manager.closers has %d closers, expected 1", len(m.closers)) + } + + m.closers[0]() + }) + } +} + +// TestSignerFailure tests the failure scenarios of the Signer method. +func TestSignerFailure(t *testing.T) { + tests := []struct { + name string + cfg *Config + plugin *mockSignerProvider + expectedError string + }{ + { + name: "provider error", + cfg: &Config{ + ID: "test-signer", + Config: map[string]string{}, + }, + plugin: &mockSignerProvider{ + err: errors.New("provider error"), + }, + expectedError: "provider error", + }, + { + name: "plugin not found", + cfg: &Config{ + ID: "nonexistent-signer", + Config: map[string]string{}, + }, + plugin: nil, + expectedError: "plugin nonexistent-signer not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a manager with the mock plugin. + m := &Manager{ + plugins: make(map[string]onixPlugin), + closers: []func(){}, + } + + // Only add the plugin if it's not nil. + if tt.plugin != nil { + m.plugins[tt.cfg.ID] = &mockPlugin{ + symbol: tt.plugin, + } + } + + // Call Signer. + signer, err := m.Signer(context.Background(), tt.cfg) + + // Check error. + if err == nil { + t.Fatal("expected error, got nil") + } else if !strings.Contains(err.Error(), tt.expectedError) { + t.Fatalf("error = %v, want error containing %q", err, tt.expectedError) + } + if signer != nil { + t.Fatal("expected nil signer, got non-nil") + } + }) + } +} + +// TestEncryptorSuccess tests the successful scenarios of the Encryptor method. +func TestEncryptorSuccess(t *testing.T) { + tests := []struct { + name string + cfg *Config + plugin *mockEncrypterProvider + }{ + { + name: "successful encrypter creation", + cfg: &Config{ + ID: "test-encrypter", + Config: map[string]string{}, + }, + plugin: &mockEncrypterProvider{ + encrypter: &mockEncrypter{}, + errFunc: func() error { return nil }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a manager with the mock plugin. + m := &Manager{ + plugins: map[string]onixPlugin{ + tt.cfg.ID: &mockPlugin{ + symbol: tt.plugin, + }, + }, + closers: []func(){}, + } + + // Call Encryptor. + encrypter, err := m.Encryptor(context.Background(), tt.cfg) + + // Check success case. + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if encrypter == nil { + t.Fatal("expected non-nil encrypter, got nil") + } + + if encrypter != tt.plugin.encrypter { + t.Fatal("encrypter does not match expected instance") + } + + if len(m.closers) != 1 { + t.Fatalf("Manager.closers has %d closers, expected 1", len(m.closers)) + } + + m.closers[0]() + }) + } +} + +// TestEncryptorFailure tests the failure scenarios of the Encryptor method. +func TestEncryptorFailure(t *testing.T) { + tests := []struct { + name string + cfg *Config + plugin *mockEncrypterProvider + expectedError string + }{ + { + name: "provider error", + cfg: &Config{ + ID: "test-encrypter", + Config: map[string]string{}, + }, + plugin: &mockEncrypterProvider{ + err: errors.New("provider error"), + }, + expectedError: "provider error", + }, + { + name: "plugin not found", + cfg: &Config{ + ID: "nonexistent-encrypter", + Config: map[string]string{}, + }, + plugin: nil, + expectedError: "plugin nonexistent-encrypter not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a manager with the mock plugin. + m := &Manager{ + plugins: make(map[string]onixPlugin), + closers: []func(){}, + } + + // Only add the plugin if it's not nil. + if tt.plugin != nil { + m.plugins[tt.cfg.ID] = &mockPlugin{ + symbol: tt.plugin, + } + } + + // Call Encryptor. + encrypter, err := m.Encryptor(context.Background(), tt.cfg) + + // Check error. + if err == nil { + t.Fatal("expected error, got nil") + } else if !strings.Contains(err.Error(), tt.expectedError) { + t.Fatalf("error = %v, want error containing %q", err, tt.expectedError) + } + if encrypter != nil { + t.Fatal("expected nil encrypter, got non-nil") + } + }) + } +} + +// TestDecryptorSuccess tests the successful scenarios of the Decryptor method. +func TestDecryptorSuccess(t *testing.T) { + tests := []struct { + name string + cfg *Config + plugin *mockDecrypterProvider + }{ + { + name: "successful decrypter creation", + cfg: &Config{ + ID: "test-decrypter", + Config: map[string]string{}, + }, + plugin: &mockDecrypterProvider{ + decrypter: &mockDecrypter{}, + errFunc: func() error { return nil }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a manager with the mock plugin. + m := &Manager{ + plugins: map[string]onixPlugin{ + tt.cfg.ID: &mockPlugin{ + symbol: tt.plugin, + }, + }, + closers: []func(){}, + } + + // Call Decryptor. + decrypter, err := m.Decryptor(context.Background(), tt.cfg) + + // Check success case. + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if decrypter == nil { + t.Fatal("expected non-nil decrypter, got nil") + } + + if decrypter != tt.plugin.decrypter { + t.Fatal("decrypter does not match expected instance") + } + + if len(m.closers) != 1 { + t.Fatalf("Manager.closers has %d closers, expected 1", len(m.closers)) + } + + m.closers[0]() + }) + } +} + +// TestDecryptorFailure tests the failure scenarios of the Decryptor method. +func TestDecryptorFailure(t *testing.T) { + tests := []struct { + name string + cfg *Config + plugin *mockDecrypterProvider + expectedError string + }{ + { + name: "provider error", + cfg: &Config{ + ID: "test-decrypter", + Config: map[string]string{}, + }, + plugin: &mockDecrypterProvider{ + err: errors.New("provider error"), + }, + expectedError: "provider error", + }, + { + name: "plugin not found", + cfg: &Config{ + ID: "nonexistent-decrypter", + Config: map[string]string{}, + }, + plugin: nil, + expectedError: "plugin nonexistent-decrypter not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a manager with the mock plugin. + m := &Manager{ + plugins: make(map[string]onixPlugin), + closers: []func(){}, + } + + // Only add the plugin if it's not nil. + if tt.plugin != nil { + m.plugins[tt.cfg.ID] = &mockPlugin{ + symbol: tt.plugin, + } + } + + // Call Decryptor. + decrypter, err := m.Decryptor(context.Background(), tt.cfg) + + // Check error. + if err == nil { + t.Fatal("expected error, got nil") + } else if !strings.Contains(err.Error(), tt.expectedError) { + t.Fatalf("error = %v, want error containing %q", err, tt.expectedError) + } + if decrypter != nil { + t.Fatal("expected nil decrypter, got non-nil") + } + }) + } +} + +// TestSignValidatorSuccess tests the successful scenarios of the SignValidator method. +func TestSignValidatorSuccess(t *testing.T) { + tests := []struct { + name string + cfg *Config + plugin *mockSignValidatorProvider + }{ + { + name: "successful sign validator creation", + cfg: &Config{ + ID: "test-sign-validator", + Config: map[string]string{}, + }, + plugin: &mockSignValidatorProvider{ + validator: &mockSignValidator{}, + errFunc: func() error { return nil }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a manager with the mock plugin. + m := &Manager{ + plugins: map[string]onixPlugin{ + tt.cfg.ID: &mockPlugin{ + symbol: tt.plugin, + }, + }, + closers: []func(){}, + } + + // Call SignValidator. + validator, err := m.SignValidator(context.Background(), tt.cfg) + + // Check success case. + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if validator == nil { + t.Fatal("expected non-nil validator, got nil") + } + if validator != tt.plugin.validator { + t.Fatal("validator does not match expected instance") + } + + if len(m.closers) != 1 { + t.Fatalf("Manager.closers has %d closers, expected 1", len(m.closers)) + } + + m.closers[0]() + }) + } +} + +// TestSignValidatorFailure tests the failure scenarios of the SignValidator method. +func TestSignValidatorFailure(t *testing.T) { + tests := []struct { + name string + cfg *Config + plugin *mockSignValidatorProvider + expectedError string + }{ + { + name: "provider error", + cfg: &Config{ + ID: "test-sign-validator", + Config: map[string]string{}, + }, + plugin: &mockSignValidatorProvider{ + err: errors.New("provider error"), + }, + expectedError: "provider error", + }, + { + name: "plugin not found", + cfg: &Config{ + ID: "nonexistent-sign-validator", + Config: map[string]string{}, + }, + plugin: nil, + expectedError: "plugin nonexistent-sign-validator not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a manager with the mock plugin. + m := &Manager{ + plugins: make(map[string]onixPlugin), + closers: []func(){}, + } + + // Only add the plugin if it's not nil. + if tt.plugin != nil { + m.plugins[tt.cfg.ID] = &mockPlugin{ + symbol: tt.plugin, + } + } + + // Call SignValidator. + validator, err := m.SignValidator(context.Background(), tt.cfg) + + // Check error. + if err == nil { + t.Fatal("expected error, got nil") + } else if !strings.Contains(err.Error(), tt.expectedError) { + t.Fatalf("error = %v, want error containing %q", err, tt.expectedError) + } + if validator != nil { + t.Fatal("expected nil validator, got non-nil") + } + }) + } +} + +// TestKeyManagerSuccess tests the successful scenarios of the KeyManager method. +func TestKeyManagerSuccess(t *testing.T) { + tests := []struct { + name string + cfg *Config + plugin *mockKeyManagerProvider + }{ + { + name: "successful key manager creation", + cfg: &Config{ + ID: "test-key-manager", + Config: map[string]string{}, + }, + plugin: &mockKeyManagerProvider{ + keyManager: &mockKeyManager{}, + errFunc: func() error { return nil }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a manager with the mock plugin. + m := &Manager{ + plugins: map[string]onixPlugin{ + tt.cfg.ID: &mockPlugin{ + symbol: tt.plugin, + }, + }, + closers: []func(){}, + } + + // Create mock cache and registry lookup. + mockCache := &mockCache{} + mockRegistry := &mockRegistryLookup{} + + // Call KeyManager. + keyManager, err := m.KeyManager(context.Background(), mockCache, mockRegistry, tt.cfg) + + // Check success case. + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if keyManager == nil { + t.Fatal("expected non-nil key manager, got nil") + } + + if keyManager != tt.plugin.keyManager { + t.Fatal("key manager does not match expected instance") + } + + if len(m.closers) != 1 { + t.Fatalf("Manager.closers has %d closers, expected 1", len(m.closers)) + } + + m.closers[0]() + }) + } +} + +// TestKeyManagerFailure tests the failure scenarios of the KeyManager method. +func TestKeyManagerFailure(t *testing.T) { + tests := []struct { + name string + cfg *Config + plugin *mockKeyManagerProvider + expectedError string + }{ + { + name: "provider error", + cfg: &Config{ + ID: "test-key-manager", + Config: map[string]string{}, + }, + plugin: &mockKeyManagerProvider{ + err: errors.New("provider error"), + }, + expectedError: "provider error", + }, + { + name: "plugin not found", + cfg: &Config{ + ID: "nonexistent-key-manager", + Config: map[string]string{}, + }, + plugin: nil, + expectedError: "plugin nonexistent-key-manager not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a manager with the mock plugin. + m := &Manager{ + plugins: make(map[string]onixPlugin), + closers: []func(){}, + } + + // Only add the plugin if it's not nil. + if tt.plugin != nil { + m.plugins[tt.cfg.ID] = &mockPlugin{ + symbol: tt.plugin, + } + } + + // Create mock cache and registry lookup. + mockCache := &mockCache{} + mockRegistry := &mockRegistryLookup{} + + // Call KeyManager. + keyManager, err := m.KeyManager(context.Background(), mockCache, mockRegistry, tt.cfg) + + // Check error. + if err == nil { + t.Fatal("expected error, got nil") + } else if !strings.Contains(err.Error(), tt.expectedError) { + t.Fatalf("error = %v, want error containing %q", err, tt.expectedError) + } + if keyManager != nil { + t.Fatal("expected nil key manager, got non-nil") + } + }) + } +} + +// TestUnzipSuccess tests the successful scenarios of the unzip function. +func TestUnzipSuccess(t *testing.T) { + tests := []struct { + name string + setupFunc func() (string, string, func()) // returns src, dest, cleanup. + verifyFunc func(t *testing.T, dest string) + }{ + { + name: "extract single file", + setupFunc: func() (string, string, func()) { + // Create a temporary directory for the test. + tempDir, err := os.MkdirTemp("", "unzip-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + // Create a zip file with a single test file. + zipPath := filepath.Join(tempDir, "test.zip") + zipFile, err := os.Create(zipPath) + if err != nil { + t.Fatalf("Failed to create zip file: %v", err) + } + + zipWriter := zip.NewWriter(zipFile) + defer zipWriter.Close() + + // Add a test file to the zip. + testFile, err := zipWriter.Create("test.txt") + if err != nil { + t.Fatalf("Failed to create file in zip: %v", err) + } + _, err = testFile.Write([]byte("test content")) + if err != nil { + t.Fatalf("Failed to write to file: %v", err) + } + + zipWriter.Close() + zipFile.Close() + + // Create destination directory. + destDir := filepath.Join(tempDir, "extracted") + return zipPath, destDir, func() { + os.RemoveAll(tempDir) + } + }, + verifyFunc: func(t *testing.T, dest string) { + // Verify the extracted file exists and has correct content. + content, err := os.ReadFile(filepath.Join(dest, "test.txt")) + if err != nil { + t.Fatalf("Failed to read extracted file: %v", err) + } + if string(content) != "test content" { + t.Fatalf("Extracted file content = %v, want %v", string(content), "test content") + } + }, + }, + { + name: "extract file in subdirectory", + setupFunc: func() (string, string, func()) { + // Create a temporary directory for the test. + tempDir, err := os.MkdirTemp("", "unzip-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + // Create a zip file with a file in a subdirectory. + zipPath := filepath.Join(tempDir, "test.zip") + zipFile, err := os.Create(zipPath) + if err != nil { + t.Fatalf("Failed to create zip file: %v", err) + } + + zipWriter := zip.NewWriter(zipFile) + defer zipWriter.Close() + + // Add a file in a subdirectory. + testFile, err := zipWriter.Create("subdir/test.txt") + if err != nil { + t.Fatalf("Failed to create file in zip: %v", err) + } + _, err = testFile.Write([]byte("subdirectory content")) + if err != nil { + t.Fatalf("Failed to write to file: %v", err) + } + + zipWriter.Close() + zipFile.Close() + + // Create destination directory. + destDir := filepath.Join(tempDir, "extracted") + return zipPath, destDir, func() { + os.RemoveAll(tempDir) + } + }, + verifyFunc: func(t *testing.T, dest string) { + // Verify the extracted file in subdirectory exists and has correct content. + content, err := os.ReadFile(filepath.Join(dest, "subdir/test.txt")) + if err != nil { + t.Fatalf("Failed to read extracted file in subdirectory: %v", err) + } + if string(content) != "subdirectory content" { + t.Fatalf("Extracted file content in subdirectory = %v, want %v", string(content), "subdirectory content") + } + }, + }, + { + name: "extract multiple files", + setupFunc: func() (string, string, func()) { + // Create a temporary directory for the test. + tempDir, err := os.MkdirTemp("", "unzip-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + // Create a zip file with multiple files. + zipPath := filepath.Join(tempDir, "test.zip") + zipFile, err := os.Create(zipPath) + if err != nil { + t.Fatalf("Failed to create zip file: %v", err) + } + + zipWriter := zip.NewWriter(zipFile) + defer zipWriter.Close() + + // Add multiple files to the zip. + files := map[string]string{ + "file1.txt": "content of file 1", + "file2.txt": "content of file 2", + "subdir/file3.txt": "content of file 3", + } + + for name, content := range files { + testFile, err := zipWriter.Create(name) + if err != nil { + t.Fatalf("Failed to create file in zip: %v", err) + } + _, err = testFile.Write([]byte(content)) + if err != nil { + t.Fatalf("Failed to write to file: %v", err) + } + } + + zipWriter.Close() + zipFile.Close() + + // Create destination directory. + destDir := filepath.Join(tempDir, "extracted") + return zipPath, destDir, func() { + os.RemoveAll(tempDir) + } + }, + verifyFunc: func(t *testing.T, dest string) { + // Verify all extracted files exist and have correct content. + expectedFiles := map[string]string{ + "file1.txt": "content of file 1", + "file2.txt": "content of file 2", + "subdir/file3.txt": "content of file 3", + } + + for path, expectedContent := range expectedFiles { + content, err := os.ReadFile(filepath.Join(dest, path)) + if err != nil { + t.Fatalf("Failed to read extracted file %s: %v", path, err) + } + if string(content) != expectedContent { + t.Fatalf("Extracted file %s content = %v, want %v", path, string(content), expectedContent) + } + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup test environment. + src, dest, cleanup := tt.setupFunc() + defer cleanup() + + // Run the test. + err := unzip(src, dest) + if err != nil { + t.Fatalf("unzip() error = %v, want nil", err) + } + + // Verify the result. + tt.verifyFunc(t, dest) + }) + } +} + +// TestUnzipFailure tests the failure scenarios of the unzip function. +func TestUnzipFailure(t *testing.T) { + tests := []struct { + name string + setupFunc func() (string, string, func()) // returns src, dest, cleanup. + expectedError string + }{ + { + name: "nonexistent source file", + setupFunc: func() (string, string, func()) { + tempDir, err := os.MkdirTemp("", "unzip-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + return "nonexistent.zip", filepath.Join(tempDir, "extracted"), func() { + os.RemoveAll(tempDir) + } + }, + expectedError: "open nonexistent.zip: no such file or directory", + }, + { + name: "invalid zip file", + setupFunc: func() (string, string, func()) { + tempDir, err := os.MkdirTemp("", "unzip-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + // Create an invalid zip file. + zipPath := filepath.Join(tempDir, "invalid.zip") + if err := os.WriteFile(zipPath, []byte("not a zip file"), 0644); err != nil { + t.Fatalf("Failed to create invalid zip file: %v", err) + } + + return zipPath, filepath.Join(tempDir, "extracted"), func() { + os.RemoveAll(tempDir) + } + }, + expectedError: "zip: not a valid zip file", + }, + { + name: "destination directory creation failure", + setupFunc: func() (string, string, func()) { + tempDir, err := os.MkdirTemp("", "unzip-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + // Create a valid zip file. + zipPath := filepath.Join(tempDir, "test.zip") + zipFile, err := os.Create(zipPath) + if err != nil { + t.Fatalf("Failed to create zip file: %v", err) + } + + zipWriter := zip.NewWriter(zipFile) + defer zipWriter.Close() + + testFile, err := zipWriter.Create("test.txt") + if err != nil { + t.Fatalf("Failed to create file in zip: %v", err) + } + _, err = testFile.Write([]byte("test content")) + if err != nil { + t.Fatalf("Failed to write to file: %v", err) + } + + zipWriter.Close() + zipFile.Close() + + // Create a file instead of a directory to cause the error. + destPath := filepath.Join(tempDir, "extracted") + if err := os.WriteFile(destPath, []byte("not a directory"), 0644); err != nil { + t.Fatalf("Failed to create file at destination: %v", err) + } + + return zipPath, destPath, func() { + os.RemoveAll(tempDir) + } + }, + expectedError: "mkdir", + }, + { + name: "file creation failure", + setupFunc: func() (string, string, func()) { + tempDir, err := os.MkdirTemp("", "unzip-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + // Create a zip file with a file that would be extracted to a read-only location. + zipPath := filepath.Join(tempDir, "test.zip") + zipFile, err := os.Create(zipPath) + if err != nil { + t.Fatalf("Failed to create zip file: %v", err) + } + + zipWriter := zip.NewWriter(zipFile) + defer zipWriter.Close() + + testFile, err := zipWriter.Create("test.txt") + if err != nil { + t.Fatalf("Failed to create file in zip: %v", err) + } + _, err = testFile.Write([]byte("test content")) + if err != nil { + t.Fatalf("Failed to write to file: %v", err) + } + + zipWriter.Close() + zipFile.Close() + + // Create a read-only directory. + destDir := filepath.Join(tempDir, "extracted") + if err := os.MkdirAll(destDir, 0555); err != nil { + t.Fatalf("Failed to create read-only directory: %v", err) + } + + return zipPath, destDir, func() { + os.RemoveAll(tempDir) + } + }, + expectedError: "permission denied", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup test environment. + src, dest, cleanup := tt.setupFunc() + defer cleanup() + + // Run the test. + err := unzip(src, dest) + if err == nil { + t.Fatalf("unzip() error = nil, want error containing %q", tt.expectedError) + } else if !strings.Contains(err.Error(), tt.expectedError) { + t.Fatalf("unzip() error = %v, want error containing %q", err, tt.expectedError) + } + }) + } +} + +// TestValidateMgrCfgSuccess tests the successful scenarios of the validateMgrCfg function. +func TestValidateMgrCfgSuccess(t *testing.T) { + tests := []struct { + name string + cfg *ManagerConfig + }{ + { + name: "valid config with root path", + cfg: &ManagerConfig{ + Root: "/path/to/plugins", + RemoteRoot: "", + }, + }, + { + name: "valid config with remote root", + cfg: &ManagerConfig{ + Root: "/path/to/plugins", + RemoteRoot: "/path/to/remote/plugins.zip", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateMgrCfg(tt.cfg) + if err != nil { + t.Fatalf("validateMgrCfg() error = %v, want nil", err) + } + }) + } +} + +func TestLoadPluginSuccess(t *testing.T) { + tests := []struct { + name string + setupFunc func() (string, string, func()) // returns path, id, cleanup. + }{ + { + name: "load valid plugin", + setupFunc: func() (string, string, func()) { + // Create a temporary directory for the test. + tempDir, err := os.MkdirTemp("", "plugin-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + // Create a mock plugin file (we can't create a real .so file in tests). + pluginPath := filepath.Join(tempDir, "test-plugin.so") + if err := os.WriteFile(pluginPath, []byte("mock plugin content"), 0644); err != nil { + t.Fatalf("Failed to create mock plugin file: %v", err) + } + + return pluginPath, "test-plugin", func() { + os.RemoveAll(tempDir) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Skip the test since we can't create real .so files in tests. + t.Skip("Cannot create real .so files in tests") + + // Setup test environment. + path, id, cleanup := tt.setupFunc() + defer cleanup() + + // Run the test. + p, elapsed, err := loadPlugin(context.Background(), path, id) + if err != nil { + t.Fatalf("loadPlugin() error = %v, want nil", err) + } + if p == nil { + t.Fatal("loadPlugin() returned nil plugin") + } + if elapsed == 0 { + t.Fatal("loadPlugin() returned zero elapsed time") + } + }) + } +} + +// TestLoadPluginFailure tests the failure scenarios of the loadPlugin function. +func TestLoadPluginFailure(t *testing.T) { + tests := []struct { + name string + setupFunc func() (string, string, func()) // returns path, id, cleanup. + expectedError string + }{ + { + name: "nonexistent plugin file", + setupFunc: func() (string, string, func()) { + tempDir, err := os.MkdirTemp("", "plugin-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + return filepath.Join(tempDir, "nonexistent.so"), "nonexistent", func() { + os.RemoveAll(tempDir) + } + }, + expectedError: "failed to open plugin nonexistent: plugin.Open", + }, + { + name: "invalid plugin file", + setupFunc: func() (string, string, func()) { + tempDir, err := os.MkdirTemp("", "plugin-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + // Create an invalid plugin file. + pluginPath := filepath.Join(tempDir, "invalid.so") + if err := os.WriteFile(pluginPath, []byte("not a valid plugin"), 0644); err != nil { + t.Fatalf("Failed to create invalid plugin file: %v", err) + } + + return pluginPath, "invalid", func() { + os.RemoveAll(tempDir) + } + }, + expectedError: "failed to open plugin invalid: plugin.Open", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup test environment. + path, id, cleanup := tt.setupFunc() + defer cleanup() + + // Run the test. + p, elapsed, err := loadPlugin(context.Background(), path, id) + if err == nil { + t.Fatalf("loadPlugin() error = nil, want error containing %q", tt.expectedError) + } else if !strings.Contains(err.Error(), tt.expectedError) { + t.Fatalf("loadPlugin() error = %v, want error containing %q", err, tt.expectedError) + } + if p != nil { + t.Fatal("loadPlugin() returned non-nil plugin for error case") + } + if elapsed != 0 { + t.Fatal("loadPlugin() returned non-zero elapsed time for error case") + } + }) + } +} + +// TestPluginsSuccess tests the successful scenarios of the plugins function. +func TestPluginsSuccess(t *testing.T) { + tests := []struct { + name string + setupFunc func() (*ManagerConfig, func()) // returns config and cleanup. + wantCount int + }{ + { + name: "empty directory", + setupFunc: func() (*ManagerConfig, func()) { + // Create a temporary directory for the test. + tempDir, err := os.MkdirTemp("", "plugins-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + cfg := &ManagerConfig{ + Root: tempDir, + RemoteRoot: "", + } + + return cfg, func() { + os.RemoveAll(tempDir) + } + }, + wantCount: 0, + }, + { + name: "directory with non-plugin files", + setupFunc: func() (*ManagerConfig, func()) { + // Create a temporary directory for the test. + tempDir, err := os.MkdirTemp("", "plugins-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + // Create some non-plugin files. + files := []string{ + "file1.txt", + "file2.json", + "file3.go", + } + for _, file := range files { + if err := os.WriteFile(filepath.Join(tempDir, file), []byte("test content"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + } + + cfg := &ManagerConfig{ + Root: tempDir, + RemoteRoot: "", + } + + return cfg, func() { + os.RemoveAll(tempDir) + } + }, + wantCount: 0, + }, + { + name: "directory with subdirectories", + setupFunc: func() (*ManagerConfig, func()) { + // Create a temporary directory for the test. + tempDir, err := os.MkdirTemp("", "plugins-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + // Create some subdirectories. + dirs := []string{ + "dir1", + "dir2/subdir", + } + for _, dir := range dirs { + if err := os.MkdirAll(filepath.Join(tempDir, dir), 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + } + + cfg := &ManagerConfig{ + Root: tempDir, + RemoteRoot: "", + } + + return cfg, func() { + os.RemoveAll(tempDir) + } + }, + wantCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup test environment. + cfg, cleanup := tt.setupFunc() + defer cleanup() + + // Run the test. + got, err := plugins(context.Background(), cfg) + if err != nil { + t.Fatalf("plugins() error = %v, want nil", err) + } + if len(got) != tt.wantCount { + t.Fatalf("plugins() returned %d plugins, want %d", len(got), tt.wantCount) + } + }) + } +} + +// TestPluginsFailure tests the failure scenarios of the plugins function. +func TestPluginsFailure(t *testing.T) { + tests := []struct { + name string + setupFunc func() (*ManagerConfig, func()) // returns config and cleanup. + expectedError string + }{ + { + name: "nonexistent directory", + setupFunc: func() (*ManagerConfig, func()) { + tempDir, err := os.MkdirTemp("", "plugins-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + os.RemoveAll(tempDir) // Remove the directory to cause an error. + + cfg := &ManagerConfig{ + Root: tempDir, + RemoteRoot: "", + } + + return cfg, func() {} + }, + expectedError: "no such file or directory", + }, + { + name: "permission denied", + setupFunc: func() (*ManagerConfig, func()) { + // Create a temporary directory for the test. + tempDir, err := os.MkdirTemp("", "plugins-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + // Remove read permission from the directory. + if err := os.Chmod(tempDir, 0); err != nil { + t.Fatalf("Failed to change directory permissions: %v", err) + } + + cfg := &ManagerConfig{ + Root: tempDir, + RemoteRoot: "", + } + + return cfg, func() { + err = os.Chmod(tempDir, 0755) // Restore permissions before cleanup. + os.RemoveAll(tempDir) + } + }, + expectedError: "permission denied", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup test environment. + cfg, cleanup := tt.setupFunc() + defer cleanup() + + // Run the test. + got, err := plugins(context.Background(), cfg) + if err == nil { + t.Fatalf("plugins() error = nil, want error containing %q", tt.expectedError) + } else if !strings.Contains(err.Error(), tt.expectedError) { + t.Fatalf("plugins() error = %v, want error containing %q", err, tt.expectedError) + } + if got != nil { + t.Fatal("plugins() returned non-nil map for error case") + } + }) + } +} + +// TestProviderSuccess tests the successful scenarios of the provider function. +func TestProviderSuccess(t *testing.T) { + tests := []struct { + name string + plugins map[string]onixPlugin + id string + wantType interface{} + }{ + { + name: "get publisher provider", + plugins: map[string]onixPlugin{ + "test-plugin": &mockPlugin{ + symbol: &mockPublisherProvider{ + publisher: &mockPublisher{}, + }, + err: nil, + }, + }, + id: "test-plugin", + wantType: (*definition.PublisherProvider)(nil), + }, + { + name: "get schema validator provider", + plugins: map[string]onixPlugin{ + "test-plugin": &mockPlugin{ + symbol: &mockSchemaValidatorProvider{ + validator: &mockSchemaValidator{}, + }, + err: nil, + }, + }, + id: "test-plugin", + wantType: (*definition.SchemaValidatorProvider)(nil), + }, + { + name: "get router provider", + plugins: map[string]onixPlugin{ + "test-plugin": &mockPlugin{ + symbol: &mockRouterProvider{ + router: &mockRouter{}, + }, + err: nil, + }, + }, + id: "test-plugin", + wantType: (*definition.RouterProvider)(nil), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Run the test. + switch tt.wantType.(type) { + case *definition.PublisherProvider: + got, err := provider[definition.PublisherProvider](tt.plugins, tt.id) + if err != nil { + t.Fatalf("provider() error = %v, want nil", err) + } + if got == nil { + t.Fatal("provider() returned nil provider") + } + case *definition.SchemaValidatorProvider: + got, err := provider[definition.SchemaValidatorProvider](tt.plugins, tt.id) + if err != nil { + t.Fatalf("provider() error = %v, want nil", err) + } + if got == nil { + t.Fatal("provider() returned nil provider") + } + case *definition.RouterProvider: + got, err := provider[definition.RouterProvider](tt.plugins, tt.id) + if err != nil { + t.Fatalf("provider() error = %v, want nil", err) + } + if got == nil { + t.Fatal("provider() returned nil provider") + } + default: + t.Fatalf("unsupported provider type: %T", tt.wantType) + } + }) + } +} + +// TestProviderFailure tests the failure scenarios of the provider function. +func TestProviderFailure(t *testing.T) { + tests := []struct { + name string + plugins map[string]onixPlugin + id string + wantErrMsg string + }{ + { + name: "plugin not found", + plugins: map[string]onixPlugin{}, + id: "nonexistent", + wantErrMsg: "plugin nonexistent not found", + }, + { + name: "lookup error", + plugins: map[string]onixPlugin{ + "test-plugin": &mockPlugin{ + symbol: nil, + err: errors.New("lookup failed"), + }, + }, + id: "test-plugin", + wantErrMsg: "lookup failed", + }, + { + name: "invalid provider type", + plugins: map[string]onixPlugin{ + "test-plugin": &mockPlugin{ + symbol: &struct{}{}, // Invalid type. + err: nil, + }, + }, + id: "test-plugin", + wantErrMsg: "failed to cast Provider for test-plugin", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test with PublisherProvider type. + got, err := provider[definition.PublisherProvider](tt.plugins, tt.id) + if err == nil { + t.Fatal("provider() expected error, got nil") + } + if !strings.Contains(err.Error(), tt.wantErrMsg) { + t.Fatalf("provider() error = %v, want error containing %v", err, tt.wantErrMsg) + } + if got != nil { + t.Fatal("provider() expected nil provider") + } + + // Test with SchemaValidatorProvider type. + gotValidator, err := provider[definition.SchemaValidatorProvider](tt.plugins, tt.id) + if err == nil { + t.Fatal("provider() expected error, got nil") + } + if !strings.Contains(err.Error(), tt.wantErrMsg) { + t.Fatalf("provider() error = %v, want error containing %v", err, tt.wantErrMsg) + } + if gotValidator != nil { + t.Fatal("provider() expected nil provider") + } + + // Test with RouterProvider type. + gotRouter, err := provider[definition.RouterProvider](tt.plugins, tt.id) + if err == nil { + t.Fatal("provider() expected error, got nil") + } + if !strings.Contains(err.Error(), tt.wantErrMsg) { + t.Fatalf("provider() error = %v, want error containing %v", err, tt.wantErrMsg) + } + if gotRouter != nil { + t.Fatal("provider() expected nil provider") + } + }) + } +} + +// TestManagerMiddlewareSuccess tests the successful scenarios of the Middleware method. +func TestMiddlewareSuccess(t *testing.T) { + tests := []struct { + name string + cfg *Config + plugin *mockMiddlewareProvider + }{ + { + name: "successful middleware creation", + cfg: &Config{ + ID: "test-middleware", + Config: map[string]string{}, + }, + plugin: &mockMiddlewareProvider{ + middleware: func(h http.Handler) http.Handler { return h }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a manager with the mock plugin. + m := &Manager{ + plugins: map[string]onixPlugin{ + tt.cfg.ID: &mockPlugin{ + symbol: tt.plugin, + }, + }, + closers: []func(){}, + } + + // Call Middleware. + middleware, err := m.Middleware(context.Background(), tt.cfg) + + // Check success case. + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if middleware == nil { + t.Fatal("expected non-nil middleware, got nil") + } + }) + } +} + +// TestManagerMiddlewareFailure tests the failure scenarios of the Middleware method. +func TestMiddlewareFailure(t *testing.T) { + tests := []struct { + name string + cfg *Config + plugin *mockMiddlewareProvider + expectedError string + }{ + { + name: "provider error", + cfg: &Config{ + ID: "test-middleware", + Config: map[string]string{}, + }, + plugin: &mockMiddlewareProvider{ + err: errors.New("provider error"), + }, + expectedError: "provider error", + }, + { + name: "plugin not found", + cfg: &Config{ + ID: "nonexistent-middleware", + Config: map[string]string{}, + }, + plugin: nil, + expectedError: "plugin nonexistent-middleware not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a manager with the mock plugin. + m := &Manager{ + plugins: make(map[string]onixPlugin), + closers: []func(){}, + } + + // Only add the plugin if it's not nil. + if tt.plugin != nil { + m.plugins[tt.cfg.ID] = &mockPlugin{ + symbol: tt.plugin, + } + } + + // Call Middleware. + middleware, err := m.Middleware(context.Background(), tt.cfg) + + // Check error. + if err == nil { + t.Fatal("expected error, got nil") + } else if !strings.Contains(err.Error(), tt.expectedError) { + t.Fatalf("error = %v, want error containing %q", err, tt.expectedError) + } + if middleware != nil { + t.Fatal("expected nil middleware, got non-nil") + } + }) + } +} diff --git a/pkg/plugin/testData/payloads/Assign Driver Assign_Driver_post_OnConfirm(Self-Pickup)/on_init.json b/pkg/plugin/testData/payloads/Assign Driver Assign_Driver_post_OnConfirm(Self-Pickup)/on_init.json deleted file mode 100644 index d591334..0000000 --- a/pkg/plugin/testData/payloads/Assign Driver Assign_Driver_post_OnConfirm(Self-Pickup)/on_init.json +++ /dev/null @@ -1,357 +0,0 @@ -{ - "context": { - "action": "on_init", - "bap_id": "example-bap.com", - "bap_uri": "https://example-bap.com/prod/trv10", - "bpp_id": "example-bpp.com", - "bpp_uri": "https://example-bpp.com/prod/seller", - "domain": "ONDC:TRV10", - "location": { - "city": { - "code": "std:080" - }, - "country": { - "code": "IND" - } - }, - "message_id": "8926b747-0362-4fcc-b795-0994a6287700", - "timestamp": "2023-12-09T14:11:32.859Z", - "transaction_id": "870782be-6757-43f1-945c-8eeaf9536259", - "ttl": "PT30S", - "version": "2.0.1" - }, - "message": { - "order": { - "cancellation_terms": [ - { - "cancellation_fee": { - "percentage": "0" - }, - "fulfillment_state": { - "descriptor": { - "code": "RIDE_ASSIGNED" - } - }, - "reason_required": true - }, - { - "cancellation_fee": { - "amount": { - "currency": "INR", - "value": "30" - } - }, - "fulfillment_state": { - "descriptor": { - "code": "RIDE_ENROUTE_PICKUP" - } - }, - "reason_required": true - }, - { - "cancellation_fee": { - "amount": { - "currency": "INR", - "value": "50" - } - }, - "fulfillment_state": { - "descriptor": { - "code": "RIDE_ARRIVED_PICKUP" - } - }, - "reason_required": true - }, - { - "cancellation_fee": { - "percentage": "100" - }, - "fulfillment_state": { - "descriptor": { - "code": "RIDE_STARTED" - } - }, - "reason_required": true - } - ], - "fulfillments": [ - { - "id": "F1", - "customer": { - "contact": { - "phone": "9876556789" - }, - "person": { - "name": "Joe Adams" - } - }, - "stops": [ - { - "location": { - "gps": "13.008935, 77.644408" - }, - "type": "START", - "instructions": { - "short_desc": "short description of the location", - "long_desc": "long description of the location" - } - }, - { - "location": { - "gps": "12.971186, 77.586812" - }, - "type": "END" - } - ], - "tags": [ - { - "descriptor": { - "code": "ROUTE_INFO", - "name": "Route Information" - }, - "display": true, - "list": [ - { - "descriptor": { - "code": "ENCODED_POLYLINE", - "name": "Path" - }, - "value": "_p~iF~ps|U_ulLnnqC_mqNvxq`@" - }, - { - "descriptor": { - "code": "WAYPOINTS", - "name": "Waypoints" - }, - "value": "[{\"gps\":\"12.909982, 77.611822\"},{\"gps\":\"12.909982,77.611822\"},{\"gps\":\"12.909982,77.611822\"},{\"gps\":\"12.909982, 77.611822\"}]" - } - ] - } - ], - "type": "DELIVERY", - "vehicle": { - "category": "AUTO_RICKSHAW", - "variant": "EV" - } - } - ], - "items": [ - { - "descriptor": { - "code": "RIDE", - "name": "Auto Ride" - }, - "fulfillment_ids": [ - "F1" - ], - "id": "I1", - "location_ids": [ - "L1", - "L3" - ], - "payment_ids": [ - "PA1" - ], - "price": { - "currency": "INR", - "maximum_value": "176", - "minimum_value": "136", - "value": "146" - }, - "tags": [ - { - "descriptor": { - "code": "FARE_POLICY", - "name": "Daytime Charges" - }, - "display": true, - "list": [ - { - "descriptor": { - "code": "MIN_FARE" - }, - "value": "30" - }, - { - "descriptor": { - "code": "MIN_FARE_DISTANCE_KM" - }, - "value": "2" - }, - { - "descriptor": { - "code": "PER_KM_CHARGE" - }, - "value": "15" - }, - { - "descriptor": { - "code": "PICKUP_CHARGE" - }, - "value": "10" - }, - { - "descriptor": { - "code": "WAITING_CHARGE_PER_MIN" - }, - "value": "2" - }, - { - "descriptor": { - "code": "NIGHT_CHARGE_MULTIPLIER" - }, - "value": "1.5" - }, - { - "descriptor": { - "code": "NIGHT_SHIFT_START_TIME" - }, - "value": "22:00:00" - }, - { - "descriptor": { - "code": "NIGHT_SHIFT_END_TIME" - }, - "value": "05:00:00" - } - ] - }, - { - "descriptor": { - "code": "INFO", - "name": "General Information" - }, - "display": true, - "list": [ - { - "descriptor": { - "code": "DISTANCE_TO_NEAREST_DRIVER_METER" - }, - "value": "661" - }, - { - "descriptor": { - "code": "ETA_TO_NEAREST_DRIVER_MIN" - }, - "value": "3" - } - ] - } - ] - } - ], - "payments": [ - { - "collected_by": "BPP", - "id": "PA1", - "params": { - "bank_account_number": "xxxxxxxxxxxxxx", - "bank_code": "XXXXXXXX", - "virtual_payment_address": "9988199772@okicic" - }, - "status": "NOT-PAID", - "tags": [ - { - "descriptor": { - "code": "BUYER_FINDER_FEES" - }, - "display": false, - "list": [ - { - "descriptor": { - "code": "BUYER_FINDER_FEES_PERCENTAGE" - }, - "value": "1" - } - ] - }, - { - "descriptor": { - "code": "SETTLEMENT_TERMS" - }, - "display": false, - "list": [ - { - "descriptor": { - "code": "DELAY_INTEREST" - }, - "value": "5" - }, - { - "descriptor": { - "code": "SETTLEMENT_TYPE" - }, - "value": "UPI" - }, - { - "descriptor": { - "code": "SETTLEMENT_WINDOW" - }, - "value": "PT2D" - }, - { - "descriptor": { - "code": "SETTLEMENT_BASIS" - }, - "value": "DELIVERY" - }, - { - "descriptor": { - "code": "MANDATORY_ARBITRATION" - }, - "value": "true" - }, - { - "descriptor": { - "code": "COURT_JURISDICTION" - }, - "value": "New Delhi" - }, - { - "descriptor": { - "code": "STATIC_TERMS" - }, - "value": "https://example-test-bpp.com/static-terms.txt" - }, - { - "descriptor": { - "code": "SETTLEMENT_AMOUNT" - }, - "value": "1.46" - } - ] - } - ], - "type": "ON-FULFILLMENT" - } - ], - "provider": { - "id": "P1" - }, - "quote": { - "breakup": [ - { - "price": { - "currency": "INR", - "value": "30" - }, - "title": "BASE_FARE" - }, - { - "price": { - "currency": "INR", - "value": "116" - }, - "title": "DISTANCE_FARE" - } - ], - "price": { - "currency": "INR", - "value": "146" - }, - "ttl": "PT30S" - } - } - } - } - \ No newline at end of file diff --git a/pkg/plugin/testData/payloads/Assign Driver Assign_Driver_post_OnConfirm(Self-Pickup)/search.json b/pkg/plugin/testData/payloads/Assign Driver Assign_Driver_post_OnConfirm(Self-Pickup)/search.json deleted file mode 100644 index ae3a356..0000000 --- a/pkg/plugin/testData/payloads/Assign Driver Assign_Driver_post_OnConfirm(Self-Pickup)/search.json +++ /dev/null @@ -1,81 +0,0 @@ -{ - "context": { - "action": "search", - "bap_id": "example-bap.com", - "bap_uri": "https://example-bap.com/prod/trv10", - "domain": "ONDC:TRV10", - "location": { - "city": { - "code": "std:080" - }, - "country": { - "code": "IND" - } - }, - "message_id": "40963dc1-e402-4f4d-ae70-7c5864ca682c", - "timestamp": "2023-12-09T13:39:56.645Z", - "transaction_id": "870782be-6757-43f1-945c-8eeaf9536259", - "ttl": "PT30S", - "version": "2.0.1" - }, - "message": { - "intent": { - "fulfillment": { - "stops": [ - { - "location": { - "gps": "13.008935, 77.644408" - }, - "type": "START" - }, - { - "location": { - "gps": "12.971186, 77.586812" - }, - "type": "END" - } - ] - }, - "payment": { - "collected_by": "BPP", - "tags": [ - { - "descriptor": { - "code": "BUYER_FINDER_FEES" - }, - "display": false, - "list": [ - { - "descriptor": { - "code": "BUYER_FINDER_FEES_PERCENTAGE" - }, - "value": "1" - } - ] - }, - { - "descriptor": { - "code": "SETTLEMENT_TERMS" - }, - "display": false, - "list": [ - { - "descriptor": { - "code": "DELAY_INTEREST" - }, - "value": "5" - }, - { - "descriptor": { - "code": "STATIC_TERMS" - }, - "value": "https://example-test-bap.com/static-terms.txt" - } - ] - } - ] - } - } - } - } - \ No newline at end of file diff --git a/pkg/plugin/testData/payloads/Assign_Driver_on_onConfirm/search.json b/pkg/plugin/testData/payloads/Assign_Driver_on_onConfirm/search.json deleted file mode 100644 index 21b14a5..0000000 --- a/pkg/plugin/testData/payloads/Assign_Driver_on_onConfirm/search.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "context": { - "action": "search", - "bap_id": "example-bap.com", - "bap_uri": "https://example-bap.com/prod/trv10", - "domain": "ONDC:TRV10", - "location": { - "city": { - "code": "std:080" - }, - "country": { - "code": "IND" - } - }, - "message_id": "40963dc1-e402-4f4d-ae70-7c5864ca682c", - "timestamp": "2023-12-09T13:40:21.452Z", - "transaction_id": "870782be-6757-43f1-945c-8eeaf9536259", - "ttl": "PT30S", - "version": "2.0.1" - }, - "message": { - "intent": { - "fulfillment": { - "stops": [ - { - "location": { - "gps": "13.008935, 77.644408" - }, - "type": "START" - }, - { - "location": { - "gps": "12.971186, 77.586812" - }, - "type": "END" - } - ] - }, - "payment": { - "collected_by": "BAP", - "tags": [ - { - "descriptor": { - "code": "BUYER_FINDER_FEES" - }, - "display": false, - "list": [ - { - "descriptor": { - "code": "BUYER_FINDER_FEES_PERCENTAGE" - }, - "value": "1" - } - ] - }, - { - "descriptor": { - "code": "SETTLEMENT_TERMS" - }, - "display": false, - "list": [ - { - "descriptor": { - "code": "SETTLEMENT_WINDOW" - }, - "value": "PT1D" - }, - { - "descriptor": { - "code": "SETTLEMENT_BASIS" - }, - "value": "DELIVERY" - }, - { - "descriptor": { - "code": "STATIC_TERMS" - }, - "value": "https://example-test-bap.com/static-terms.txt" - } - ] - } - ] - } - } - } -} diff --git a/pkg/plugin/testdata/dummy.go b/pkg/plugin/testdata/dummy.go new file mode 100644 index 0000000..31e0044 --- /dev/null +++ b/pkg/plugin/testdata/dummy.go @@ -0,0 +1,18 @@ +package main + +import ( + "context" + + "github.com/beckn/beckn-onix/pkg/plugin/definition" + "github.com/beckn/beckn-onix/pkg/plugin/implementation/encrypter" +) + +// encrypterProvider implements the definition.encrypterProvider interface. +type encrypterProvider struct{} + +func (ep encrypterProvider) New(ctx context.Context, config map[string]string) (definition.Encrypter, func() error, error) { + return encrypter.New(ctx) +} + +// Provider is the exported symbol that the plugin manager will look for. +var Provider = encrypterProvider{} diff --git a/pkg/response/response.go b/pkg/response/response.go index be87f9f..0ced3de 100644 --- a/pkg/response/response.go +++ b/pkg/response/response.go @@ -3,150 +3,87 @@ package response import ( "context" "encoding/json" + "errors" "fmt" - "strings" + "net/http" + + "github.com/beckn/beckn-onix/pkg/log" + "github.com/beckn/beckn-onix/pkg/model" ) -type ErrorType string - -const ( - SchemaValidationErrorType ErrorType = "SCHEMA_VALIDATION_ERROR" - InvalidRequestErrorType ErrorType = "INVALID_REQUEST" -) - -type BecknRequest struct { - Context map[string]interface{} `json:"context,omitempty"` -} -type Error struct { - Code string `json:"code,omitempty"` - Message string `json:"message,omitempty"` - Paths string `json:"paths,omitempty"` -} - -// SchemaValidationErr represents a collection of schema validation failures. -type SchemaValidationErr struct { - Errors []Error -} - -// Error implements the error interface for SchemaValidationErr. -func (e *SchemaValidationErr) Error() string { - var errorMessages []string - for _, err := range e.Errors { - errorMessages = append(errorMessages, fmt.Sprintf("%s: %s", err.Paths, err.Message)) - } - return strings.Join(errorMessages, "; ") -} - -type Message struct { - Ack struct { - Status string `json:"status,omitempty"` - } `json:"ack,omitempty"` - Error *Error `json:"error,omitempty"` -} -type BecknResponse struct { - Context map[string]interface{} `json:"context,omitempty"` - Message Message `json:"message,omitempty"` -} -type ClientFailureBecknResponse struct { - Context map[string]interface{} `json:"context,omitempty"` - Error *Error `json:"error,omitempty"` -} - -var errorMap = map[ErrorType]Error{ - SchemaValidationErrorType: { - Code: "400", - Message: "Schema validation failed", - }, - InvalidRequestErrorType: { - Code: "401", - Message: "Invalid request format", - }, -} -var DefaultError = Error{ - Code: "500", - Message: "Internal server error", -} - -func Nack(ctx context.Context, tp ErrorType, paths string, body []byte) ([]byte, error) { - var req BecknRequest - if err := json.Unmarshal(body, &req); err != nil { - return nil, fmt.Errorf("failed to parse request: %w", err) - } - errorObj, ok := errorMap[tp] - if paths != "" { - errorObj.Paths = paths - } - var response BecknResponse - if !ok { - response = BecknResponse{ - Context: req.Context, - Message: Message{ - Ack: struct { - Status string `json:"status,omitempty"` - }{ - Status: "NACK", - }, - Error: &DefaultError, - }, - } - } else { - response = BecknResponse{ - Context: req.Context, - Message: Message{ - Ack: struct { - Status string `json:"status,omitempty"` - }{ - Status: "NACK", - }, - Error: &errorObj, - }, - } - } - return json.Marshal(response) -} - -// Ack processes the incoming Beckn request, unmarshals the JSON body into a BecknRequest struct, -// and returns a JSON-encoded acknowledgment response with a status of "ACK". -// If the request body cannot be parsed, it returns an error. -func Ack(ctx context.Context, body []byte) ([]byte, error) { - var req BecknRequest - if err := json.Unmarshal(body, &req); err != nil { - return nil, fmt.Errorf("failed to parse request: %w", err) - } - response := BecknResponse{ - Context: req.Context, - Message: Message{ - Ack: struct { - Status string `json:"status,omitempty"` - }{ - Status: "ACK", +// SendAck sends an acknowledgment response (ACK) to the client. +func SendAck(w http.ResponseWriter) { + resp := &model.Response{ + Message: model.Message{ + Ack: model.Ack{ + Status: model.StatusACK, }, }, } - return json.Marshal(response) + + data, _ := json.Marshal(resp) //should not fail here + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write(data) + if err != nil { + http.Error(w, "failed to write response", http.StatusInternalServerError) + return + } } -// HandleClientFailure processes a client failure scenario by unmarshaling the provided -// request body, determining the appropriate error response based on the given ErrorType, -// and returning the serialized response. If the ErrorType is not found in the error map, -// a default error is used. -func HandleClientFailure(ctx context.Context, tp ErrorType, body []byte) ([]byte, error) { - var req BecknRequest - if err := json.Unmarshal(body, &req); err != nil { - return nil, fmt.Errorf("failed to parse request: %w", err) +// nack sends a negative acknowledgment (NACK) response with an error message. +func nack(ctx context.Context, w http.ResponseWriter, err *model.Error, status int) { + resp := &model.Response{ + Message: model.Message{ + Ack: model.Ack{ + Status: model.StatusNACK, + }, + Error: err, + }, } - errorObj, ok := errorMap[tp] - var response ClientFailureBecknResponse - if !ok { - response = ClientFailureBecknResponse{ - Context: req.Context, - Error: &DefaultError, - } - } else { - response = ClientFailureBecknResponse{ - Context: req.Context, - Error: &errorObj, - } + data, _ := json.Marshal(resp) //should not fail here + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, er := w.Write(data) + if er != nil { + log.Debugf(ctx, "Error writing response: %v, MessageID: %s", er, ctx.Value(model.ContextKeyMsgID)) + http.Error(w, fmt.Sprintf("Internal server error, MessageID: %s", ctx.Value(model.ContextKeyMsgID)), http.StatusInternalServerError) + return + } +} + +// internalServerError generates an internal server error response. +func internalServerError(ctx context.Context) *model.Error { + return &model.Error{ + Code: http.StatusText(http.StatusInternalServerError), + Message: fmt.Sprintf("Internal server error, MessageID: %s", ctx.Value(model.ContextKeyMsgID)), + } +} + +// SendNack processes different types of errors and sends an appropriate NACK response. +func SendNack(ctx context.Context, w http.ResponseWriter, err error) { + var schemaErr *model.SchemaValidationErr + var signErr *model.SignValidationErr + var badReqErr *model.BadReqErr + var notFoundErr *model.NotFoundErr + + switch { + case errors.As(err, &schemaErr): + nack(ctx, w, schemaErr.BecknError(), http.StatusBadRequest) + return + case errors.As(err, &signErr): + nack(ctx, w, signErr.BecknError(), http.StatusUnauthorized) + return + case errors.As(err, &badReqErr): + nack(ctx, w, badReqErr.BecknError(), http.StatusBadRequest) + return + case errors.As(err, ¬FoundErr): + nack(ctx, w, notFoundErr.BecknError(), http.StatusNotFound) + return + default: + nack(ctx, w, internalServerError(ctx), http.StatusInternalServerError) + return } - return json.Marshal(response) } diff --git a/pkg/response/response_test.go b/pkg/response/response_test.go new file mode 100644 index 0000000..f0c1c6d --- /dev/null +++ b/pkg/response/response_test.go @@ -0,0 +1,241 @@ +package response + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/beckn/beckn-onix/pkg/model" +) + +type errorResponseWriter struct{} + +// TODO: Optimize the cases by removing these +func (e *errorResponseWriter) Write([]byte) (int, error) { + return 0, errors.New("write error") +} +func (e *errorResponseWriter) WriteHeader(statusCode int) {} + +func (e *errorResponseWriter) Header() http.Header { + return http.Header{} +} + +func TestSendAck(t *testing.T) { + _, err := http.NewRequest("GET", "/", nil) + if err != nil { + t.Fatal(err) // For tests + } + rr := httptest.NewRecorder() + + SendAck(rr) + + if rr.Code != http.StatusOK { + t.Errorf("wanted status code %d, got %d", http.StatusOK, rr.Code) + } + + expected := `{"message":{"ack":{"status":"ACK"}}}` + if rr.Body.String() != expected { + t.Errorf("err.Error() = %s, want %s", + rr.Body.String(), expected) + + } +} + +func TestSendNack(t *testing.T) { + ctx := context.WithValue(context.Background(), model.ContextKeyMsgID, "123456") + + tests := []struct { + name string + err error + expected string + status int + }{ + { + name: "SchemaValidationErr", + err: &model.SchemaValidationErr{ + Errors: []model.Error{ + {Paths: "/path1", Message: "Error 1"}, + {Paths: "/path2", Message: "Error 2"}, + }, + }, + status: http.StatusBadRequest, + expected: `{"message":{"ack":{"status":"NACK"},"error":{"code":"Bad Request","paths":"/path1;/path2","message":"Error 1; Error 2"}}}`, + }, + { + name: "SignValidationErr", + err: model.NewSignValidationErr(errors.New("signature invalid")), + status: http.StatusUnauthorized, + expected: `{"message":{"ack":{"status":"NACK"},"error":{"code":"Unauthorized","message":"Signature Validation Error: signature invalid"}}}`, + }, + { + name: "BadReqErr", + err: model.NewBadReqErr(errors.New("bad request error")), + status: http.StatusBadRequest, + expected: `{"message":{"ack":{"status":"NACK"},"error":{"code":"Bad Request","message":"BAD Request: bad request error"}}}`, + }, + { + name: "NotFoundErr", + err: model.NewNotFoundErr(errors.New("endpoint not found")), + status: http.StatusNotFound, + expected: `{"message":{"ack":{"status":"NACK"},"error":{"code":"Not Found","message":"Endpoint not found: endpoint not found"}}}`, + }, + { + name: "InternalServerError", + err: errors.New("unexpected error"), + status: http.StatusInternalServerError, + expected: `{"message":{"ack":{"status":"NACK"},"error":{"code":"Internal Server Error","message":"Internal server error, MessageID: 123456"}}}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := http.NewRequest("GET", "/", nil) + if err != nil { + t.Fatal(err) // For tests + } + rr := httptest.NewRecorder() + + SendNack(ctx, rr, tt.err) + + if rr.Code != tt.status { + t.Errorf("wanted status code %d, got %d", tt.status, rr.Code) + } + + var actual map[string]interface{} + err = json.Unmarshal(rr.Body.Bytes(), &actual) + if err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + var expected map[string]interface{} + err = json.Unmarshal([]byte(tt.expected), &expected) + if err != nil { + t.Fatalf("failed to unmarshal expected response: %v", err) + } + + if !compareJSON(expected, actual) { + t.Errorf("err.Error() = %s, want %s", + actual, expected) + } + + }) + } +} + +func compareJSON(expected, actual map[string]interface{}) bool { + expectedBytes, _ := json.Marshal(expected) + actualBytes, _ := json.Marshal(actual) + return bytes.Equal(expectedBytes, actualBytes) +} + +func TestSendAck_WriteError(t *testing.T) { + w := &errorResponseWriter{} + SendAck(w) +} + +// Mock struct to force JSON marshalling error +type badMessage struct{} + +func (b *badMessage) MarshalJSON() ([]byte, error) { + return nil, errors.New("marshal error") +} + +func TestNack_1(t *testing.T) { + tests := []struct { + name string + err *model.Error + status int + expected string + useBadJSON bool + useBadWrite bool + }{ + { + name: "Schema Validation Error", + err: &model.Error{ + Code: "BAD_REQUEST", + Paths: "/test/path", + Message: "Invalid schema", + }, + status: http.StatusBadRequest, + expected: `{"message":{"ack":{"status":"NACK"},"error":{"code":"BAD_REQUEST","paths":"/test/path","message":"Invalid schema"}}}`, + }, + { + name: "Internal Server Error", + err: &model.Error{ + Code: "INTERNAL_SERVER_ERROR", + Message: "Something went wrong", + }, + status: http.StatusInternalServerError, + expected: `{"message":{"ack":{"status":"NACK"},"error":{"code":"INTERNAL_SERVER_ERROR","message":"Something went wrong"}}}`, + }, + { + name: "JSON Marshal Error", + err: nil, // This will be overridden to cause marshaling error + status: http.StatusInternalServerError, + expected: `Internal server error, MessageID: 12345`, + useBadJSON: true, + }, + { + name: "Write Error", + err: &model.Error{ + Code: "WRITE_ERROR", + Message: "Failed to write response", + }, + status: http.StatusInternalServerError, + expected: `Internal server error, MessageID: 12345`, + useBadWrite: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, err := http.NewRequest("GET", "/", nil) + if err != nil { + t.Fatal(err) + } + ctx := context.WithValue(req.Context(), model.ContextKeyMsgID, "12345") + + var w http.ResponseWriter + if tt.useBadWrite { + w = &errorResponseWriter{} // Simulate write error + } else { + w = httptest.NewRecorder() + } + + // TODO: Fix this approach , should not be used like this. + if tt.useBadJSON { + data, _ := json.Marshal(&badMessage{}) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.status) + _, err := w.Write(data) + if err != nil { + http.Error(w, "Failed to write response", http.StatusInternalServerError) + return + } + return + } + + nack(ctx, w, tt.err, tt.status) + if !tt.useBadWrite { + recorder, ok := w.(*httptest.ResponseRecorder) + if !ok { + t.Fatal("Failed to cast response recorder") + } + + if recorder.Code != tt.status { + t.Errorf("wanted status code %d, got %d", tt.status, recorder.Code) + } + + body := recorder.Body.String() + if body != tt.expected { + t.Errorf("err.Error() = %s, want %s", + body, tt.expected) + } + } + }) + } +} diff --git a/schemas.zip b/schemas.zip new file mode 100644 index 0000000..d72c46d Binary files /dev/null and b/schemas.zip differ diff --git a/test.go b/test.go deleted file mode 100644 index d319abb..0000000 --- a/test.go +++ /dev/null @@ -1,92 +0,0 @@ -package main - -import ( - "context" - "fmt" - "io" - "log" - "net/http" - "net/url" - - "github.com/beckn/beckn-onix/pkg/plugin/definition" - - "github.com/beckn/beckn-onix/pkg/plugin" -) - -var ( - manager *plugin.Manager -) - -func main() { - var err error - // Load the configuration. - config, err := plugin.LoadConfig("pkg/plugin/plugin.yaml") - if err != nil { - log.Fatalf("Failed to load plugins configuration: %v", err) - } - - // Initialize the plugin manager. - manager, err = plugin.NewManager(context.Background(), config) - if err != nil { - log.Fatalf("Failed to create PluginManager: %v", err) - } - - // Get the validator. - validator, _, defErr := manager.SchemaValidator(context.Background()) - if defErr != nil { - log.Fatalf("Failed to get validators: %v", defErr) - } - - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - validateHandler(w, r, validator) - }) - fmt.Println("Starting server on port 8084...") - err = http.ListenAndServe(":8084", nil) - if err != nil { - log.Fatalf("Server failed to start: %v", err) - } -} - -func validateHandler(w http.ResponseWriter, r *http.Request, validators definition.SchemaValidator) { - if r.Method != http.MethodPost { - http.Error(w, "Invalid request method", http.StatusMethodNotAllowed) - return - } - - // Extract endpoint from request URL. - requestURL := r.RequestURI - u, err := url.ParseRequestURI(requestURL) - if err != nil { - http.Error(w, "Failed to parse request URL", http.StatusBadRequest) - return - } - - payloadData, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, "Failed to read payload data", http.StatusInternalServerError) - return - } - - ctx := context.Background() - // validationErr := validators.Validate(ctx, u, payloadData) - // if validationErr != (definition.SchemaValError{}) { - // http.Error(w, fmt.Sprintf("Document validation failed: %v", validationErr), http.StatusBadRequest) - // } else if !valid { - // http.Error(w, "Document validation failed", http.StatusBadRequest) - // } else { - // w.WriteHeader(http.StatusOK) - // if _, err := w.Write([]byte("Document validation succeeded!")); err != nil { - // log.Fatalf("Failed to write response: %v", err) - // } - // } - validationErr := validators.Validate(ctx, u, payloadData) - if validationErr != nil { - // Handle other types of errors - http.Error(w, fmt.Sprintf("Schema validation failed: %v", validationErr), http.StatusBadRequest) - } else { - w.WriteHeader(http.StatusOK) - if _, err := w.Write([]byte("Schema validation succeeded!")); err != nil { - log.Fatalf("Failed to write response: %v", err) - } - } -}