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..c2fe961 --- /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 (80%), fail the job + if (( $(echo "$coverage < 80" | bc -l) )); then + echo "Coverage for $coverage_file is below 80%. 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/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/go.mod b/go.mod index f050463..eefc09c 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,16 @@ module github.com/beckn/beckn-onix go 1.23.4 +toolchain go1.23.7 require ( github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 gopkg.in/yaml.v2 v2.4.0 + golang.org/x/crypto v0.36.0 ) -require golang.org/x/text v0.14.0 // indirect +require ( + golang.org/x/sys v0.31.0 // indirect + gopkg.in/yaml.v2 v2.4.0 + golang.org/x/text v0.14.0 // indirect +) \ No newline at end of file diff --git a/go.sum b/go.sum index 2c8aa28..cd14f3b 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,12 @@ github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxK github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 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= +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.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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= diff --git a/pkg/plugin/implementation/schemaValidator/cmd/plugin.go b/pkg/plugin/implementation/schemaValidator/cmd/plugin.go index fbe61b7..6882ce4 100644 --- a/pkg/plugin/implementation/schemaValidator/cmd/plugin.go +++ b/pkg/plugin/implementation/schemaValidator/cmd/plugin.go @@ -5,10 +5,10 @@ import ( "errors" definition "github.com/beckn/beckn-onix/pkg/plugin/definition" - validator "github.com/beckn/beckn-onix/pkg/plugin/implementation/schemaValidator" + schemaValidator "github.com/beckn/beckn-onix/pkg/plugin/implementation/schemaValidator" ) -// ValidatorProvider provides instance of Validator. +// schemaValidatorProvider provides instances of schemaValidator. type schemaValidatorProvider struct{} // New initializes a new Verifier instance. @@ -23,11 +23,11 @@ func (vp schemaValidatorProvider) New(ctx context.Context, config map[string]str return nil, nil, errors.New("config must contain 'schema_dir'") } - // Create a new Validator instance with the provided configuration - return validator.New(ctx, &validator.Config{ - SchemaDir: schemaDir, // Pass the schemaDir to the validator.Config + // 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 definition.SchemaValidatorProvider = &schemaValidatorProvider{} +var Provider definition.SchemaValidatorProvider = schemaValidatorProvider{} diff --git a/pkg/plugin/implementation/schemaValidator/cmd/plugin_test.go b/pkg/plugin/implementation/schemaValidator/cmd/plugin_test.go index 99914a2..7e06b55 100644 --- a/pkg/plugin/implementation/schemaValidator/cmd/plugin_test.go +++ b/pkg/plugin/implementation/schemaValidator/cmd/plugin_test.go @@ -72,7 +72,7 @@ func TestValidatorProviderSuccess(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { vp := schemaValidatorProvider{} - validator, close, err := vp.New(tt.ctx, tt.config) + schemaValidator, close, err := vp.New(tt.ctx, tt.config) // Ensure no error occurred if err != nil { @@ -80,9 +80,9 @@ func TestValidatorProviderSuccess(t *testing.T) { return } - // Ensure the validator is not nil - if validator == nil { - t.Error("expected a non-nil validator, got nil") + // Ensure the schemaValidator is not nil + if schemaValidator == nil { + t.Error("expected a non-nil schemaValidator, got nil") } // Ensure the close function is not nil @@ -110,11 +110,23 @@ func TestValidatorProviderFailure(t *testing.T) { config map[string]string expectedError string }{ + { + name: "Config is empty", + ctx: context.Background(), + config: map[string]string{}, + expectedError: "config must contain 'schema_dir'", + }, + { + name: "schema_dir is empty", + ctx: context.Background(), + config: map[string]string{"schema_dir": ""}, + expectedError: "config must contain 'schema_dir'", + }, { name: "Invalid schema directory", ctx: context.Background(), // Valid context config: map[string]string{"schema_dir": "/invalid/dir"}, - expectedError: "failed to initialise validator: schema directory does not exist: /invalid/dir", + expectedError: "failed to initialise schemaValidator: schema directory does not exist: /invalid/dir", }, { name: "Nil context", diff --git a/pkg/plugin/implementation/schemaValidator/schemaValidator.go b/pkg/plugin/implementation/schemaValidator/schemaValidator.go index 7f57c4e..a46ceb2 100644 --- a/pkg/plugin/implementation/schemaValidator/schemaValidator.go +++ b/pkg/plugin/implementation/schemaValidator/schemaValidator.go @@ -10,7 +10,7 @@ import ( "path/filepath" "strings" - definition "github.com/beckn/beckn-onix/pkg/plugin/definition" + response "github.com/beckn/beckn-onix/pkg/response" "github.com/santhosh-tekuri/jsonschema/v6" ) @@ -23,12 +23,13 @@ type payload struct { } `json:"context"` } -// Validator implements the Validator interface. +// SchemaValidator implements the Validator interface. type SchemaValidator struct { config *Config schemaCache map[string]*jsonschema.Schema } +// Config struct for SchemaValidator. type Config struct { SchemaDir string } @@ -45,8 +46,8 @@ func New(ctx context.Context, config *Config) (*SchemaValidator, func() error, e } // Call Initialise function to load schemas and get validators - if err := v.Initialise(); err != nil { - return nil, nil, fmt.Errorf("failed to initialise validator: %v", err) + if err := v.initialise(); err != nil { + return nil, nil, fmt.Errorf("failed to initialise schemaValidator: %v", err) } return v, v.Close, nil } @@ -88,20 +89,20 @@ func (v *SchemaValidator) Validate(ctx context.Context, url *url.URL, data []byt // Handle schema validation errors if validationErr, ok := err.(*jsonschema.ValidationError); ok { // Convert validation errors into an array of SchemaValError - var schemaErrors []definition.SchemaValError + var schemaErrors []response.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, definition.SchemaValError{ - Path: path, + schemaErrors = append(schemaErrors, response.Error{ + Paths: path, Message: message, }) } // Return the array of schema validation errors - return &definition.SchemaValidationErr{Errors: schemaErrors} + return &response.SchemaValidationErr{Errors: schemaErrors} } // Return a generic error for non-validation errors return fmt.Errorf("validation failed: %v", err) @@ -116,7 +117,7 @@ type ValidatorProvider struct{} // 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 { +func (v *SchemaValidator) initialise() error { schemaDir := v.config.SchemaDir // Check if the directory exists and is accessible. info, err := os.Stat(schemaDir) diff --git a/pkg/plugin/implementation/schemaValidator/schemaValidator_test.go b/pkg/plugin/implementation/schemaValidator/schemaValidator_test.go index 1fe54d5..b60c834 100644 --- a/pkg/plugin/implementation/schemaValidator/schemaValidator_test.go +++ b/pkg/plugin/implementation/schemaValidator/schemaValidator_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/beckn/beckn-onix/pkg/plugin/definition" + "github.com/santhosh-tekuri/jsonschema/v6" ) // setupTestSchema creates a temporary directory and writes a sample schema file. @@ -54,16 +54,16 @@ func setupTestSchema(t *testing.T) string { func TestValidator_Validate_Success(t *testing.T) { tests := []struct { - name string - url string - payload string - wantValid bool + 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"}}`, - wantValid: true, + name: "Valid payload", + url: "http://example.com/endpoint", + payload: `{"context": {"domain": "example", "version": "1.0", "action": "endpoint"}}`, + wantErr: false, }, } @@ -80,12 +80,11 @@ func TestValidator_Validate_Success(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { u, _ := url.Parse(tt.url) - valid, err := v.Validate(context.Background(), u, []byte(tt.payload)) - if err != (definition.SchemaValError{}) { + err := v.Validate(context.Background(), u, []byte(tt.payload)) + if err != nil { t.Errorf("Unexpected error: %v", err) - } - if valid != tt.wantValid { - t.Errorf("Error: Validate() returned valid = %v, expected valid = %v", valid, tt.wantValid) + } else { + t.Logf("Test %s passed with no errors", tt.name) } }) } @@ -93,32 +92,28 @@ func TestValidator_Validate_Success(t *testing.T) { func TestValidator_Validate_Failure(t *testing.T) { tests := []struct { - name string - url string - payload string - wantValid bool - wantErr string + name string + url string + payload string + wantErr string }{ { - name: "Invalid JSON payload", - url: "http://example.com/endpoint", - payload: `{"context": {"domain": "example", "version": "1.0"`, - wantValid: false, - wantErr: "failed to parse JSON payload", + 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"}}`, - wantValid: false, - wantErr: "Validation failed", + 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"}}`, - wantValid: false, - wantErr: "schema not found for domain", + name: "Schema not found", + url: "http://example.com/unknown_endpoint", + payload: `{"context": {"domain": "example", "version": "1.0"}}`, + wantErr: "schema not found for domain", }, } @@ -135,13 +130,21 @@ func TestValidator_Validate_Failure(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { u, _ := url.Parse(tt.url) - valid, err := v.Validate(context.Background(), u, []byte(tt.payload)) - if (err != (definition.SchemaValError{}) && !strings.Contains(err.Message, tt.wantErr)) || (err == (definition.SchemaValError{}) && tt.wantErr != "") { - t.Errorf("Error: Validate() returned error = %v, expected error = %v", err, tt.wantErr) - return - } - if valid != tt.wantValid { - t.Errorf("Validate() returned valid = %v, expected valid = %v", valid, tt.wantValid) + 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) + } } }) } @@ -269,11 +272,14 @@ func TestValidator_Initialise(t *testing.T) { } config := &Config{SchemaDir: schemaDir} - v := &Validator{config: config} + v := &SchemaValidator{ + config: config, + schemaCache: make(map[string]*jsonschema.Schema), + } - err := v.Initialise() + 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) + 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 { @@ -309,22 +315,22 @@ func TestValidator_New_Failure(t *testing.T) { }, wantErr: "config cannot be nil", }, - { - name: "Config is empty", - config: &Config{}, - setupFunc: func(schemaDir string) error { - return nil - }, - wantErr: "config must contain 'schema_dir'", - }, - { - name: "schema_dir is empty", - config: &Config{SchemaDir: ""}, - setupFunc: func(schemaDir string) error { - return nil - }, - wantErr: "config must contain 'schema_dir'", - }, + // { + // name: "Config is empty", + // config: &Config{}, + // setupFunc: func(schemaDir string) error { + // return nil + // }, + // wantErr: "config must contain 'schema_dir'", + // }, + // { + // name: "schema_dir is empty", + // config: &Config{SchemaDir: ""}, + // setupFunc: func(schemaDir string) error { + // return nil + // }, + // wantErr: "config must contain 'schema_dir'", + // }, { name: "Failed to initialise validators", config: &Config{ @@ -334,7 +340,7 @@ func TestValidator_New_Failure(t *testing.T) { // Do not create the schema directory return nil }, - wantErr: "failed to initialise validator", + wantErr: "ailed to initialise schemaValidator: schema directory does not exist: /invalid/path", }, } diff --git a/pkg/response/response.go b/pkg/response/response.go new file mode 100644 index 0000000..310d06f --- /dev/null +++ b/pkg/response/response.go @@ -0,0 +1,143 @@ +package response + +import ( + "context" + "encoding/json" + "fmt" +) + +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"` +} + +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) +} + +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", + }, + }, + } + + return json.Marshal(response) +} + +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) + } + + errorObj, ok := errorMap[tp] + var response ClientFailureBecknResponse + + if !ok { + response = ClientFailureBecknResponse{ + Context: req.Context, + Error: &DefaultError, + } + } else { + response = ClientFailureBecknResponse{ + Context: req.Context, + Error: &errorObj, + } + } + + return json.Marshal(response) +} diff --git a/pkg/response/response_test.go b/pkg/response/response_test.go new file mode 100644 index 0000000..242fa72 --- /dev/null +++ b/pkg/response/response_test.go @@ -0,0 +1,303 @@ +package response + +import ( + "context" + "encoding/json" + "reflect" + "testing" +) + +func TestNack(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + errorType ErrorType + requestBody string + wantStatus string + wantErrCode string + wantErrMsg string + wantErr bool + path string + }{ + { + name: "Schema validation error", + errorType: SchemaValidationErrorType, + requestBody: `{"context": {"domain": "test-domain", "location": "test-location"}}`, + wantStatus: "NACK", + wantErrCode: "400", + wantErrMsg: "Schema validation failed", + wantErr: false, + path: "test", + }, + { + name: "Invalid request error", + errorType: InvalidRequestErrorType, + requestBody: `{"context": {"domain": "test-domain"}}`, + wantStatus: "NACK", + wantErrCode: "401", + wantErrMsg: "Invalid request format", + wantErr: false, + path: "test", + }, + { + name: "Unknown error type", + errorType: "UNKNOWN_ERROR", + requestBody: `{"context": {"domain": "test-domain"}}`, + wantStatus: "NACK", + wantErrCode: "500", + wantErrMsg: "Internal server error", + wantErr: false, + path: "test", + }, + { + name: "Empty request body", + errorType: SchemaValidationErrorType, + requestBody: `{}`, + wantStatus: "NACK", + wantErrCode: "400", + wantErrMsg: "Schema validation failed", + wantErr: false, + path: "test", + }, + { + name: "Invalid JSON", + errorType: SchemaValidationErrorType, + requestBody: `{invalid json}`, + wantErr: true, + path: "test", + }, + { + name: "Complex nested context", + errorType: SchemaValidationErrorType, + requestBody: `{"context": {"domain": "test-domain", "nested": {"key1": "value1", "key2": 123}}}`, + wantStatus: "NACK", + wantErrCode: "400", + wantErrMsg: "Schema validation failed", + wantErr: false, + path: "test", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := Nack(ctx, tt.errorType, tt.path, []byte(tt.requestBody)) + + if (err != nil) != tt.wantErr { + t.Errorf("Nack() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr && err != nil { + return + } + + var becknResp BecknResponse + if err := json.Unmarshal(resp, &becknResp); err != nil { + t.Errorf("Failed to unmarshal response: %v", err) + return + } + + if becknResp.Message.Ack.Status != tt.wantStatus { + t.Errorf("Nack() status = %v, want %v", becknResp.Message.Ack.Status, tt.wantStatus) + } + + if becknResp.Message.Error.Code != tt.wantErrCode { + t.Errorf("Nack() error code = %v, want %v", becknResp.Message.Error.Code, tt.wantErrCode) + } + + if becknResp.Message.Error.Message != tt.wantErrMsg { + t.Errorf("Nack() error message = %v, want %v", becknResp.Message.Error.Message, tt.wantErrMsg) + } + + var origReq BecknRequest + if err := json.Unmarshal([]byte(tt.requestBody), &origReq); err == nil { + if !compareContexts(becknResp.Context, origReq.Context) { + t.Errorf("Nack() context not preserved, got = %v, want %v", becknResp.Context, origReq.Context) + } + } + }) + } +} + +func TestAck(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + requestBody string + wantStatus string + wantErr bool + }{ + { + name: "Valid request", + requestBody: `{"context": {"domain": "test-domain", "location": "test-location"}}`, + wantStatus: "ACK", + wantErr: false, + }, + { + name: "Empty context", + requestBody: `{"context": {}}`, + wantStatus: "ACK", + wantErr: false, + }, + { + name: "Invalid JSON", + requestBody: `{invalid json}`, + wantErr: true, + }, + { + name: "Complex nested context", + requestBody: `{"context": {"domain": "test-domain", "nested": {"key1": "value1", "key2": 123, "array": [1,2,3]}}}`, + wantStatus: "ACK", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := Ack(ctx, []byte(tt.requestBody)) + + if (err != nil) != tt.wantErr { + t.Errorf("Ack() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr && err != nil { + return + } + + var becknResp BecknResponse + if err := json.Unmarshal(resp, &becknResp); err != nil { + t.Errorf("Failed to unmarshal response: %v", err) + return + } + + if becknResp.Message.Ack.Status != tt.wantStatus { + t.Errorf("Ack() status = %v, want %v", becknResp.Message.Ack.Status, tt.wantStatus) + } + + if becknResp.Message.Error != nil { + t.Errorf("Ack() should not have error, got %v", becknResp.Message.Error) + } + + var origReq BecknRequest + if err := json.Unmarshal([]byte(tt.requestBody), &origReq); err == nil { + if !compareContexts(becknResp.Context, origReq.Context) { + t.Errorf("Ack() context not preserved, got = %v, want %v", becknResp.Context, origReq.Context) + } + } + }) + } +} + +func TestHandleClientFailure(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + errorType ErrorType + requestBody string + wantErrCode string + wantErrMsg string + wantErr bool + }{ + { + name: "Schema validation error", + errorType: SchemaValidationErrorType, + requestBody: `{"context": {"domain": "test-domain", "location": "test-location"}}`, + wantErrCode: "400", + wantErrMsg: "Schema validation failed", + wantErr: false, + }, + { + name: "Invalid request error", + errorType: InvalidRequestErrorType, + requestBody: `{"context": {"domain": "test-domain"}}`, + wantErrCode: "401", + wantErrMsg: "Invalid request format", + wantErr: false, + }, + { + name: "Unknown error type", + errorType: "UNKNOWN_ERROR", + requestBody: `{"context": {"domain": "test-domain"}}`, + wantErrCode: "500", + wantErrMsg: "Internal server error", + wantErr: false, + }, + { + name: "Invalid JSON", + errorType: SchemaValidationErrorType, + requestBody: `{invalid json}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := HandleClientFailure(ctx, tt.errorType, []byte(tt.requestBody)) + + if (err != nil) != tt.wantErr { + t.Errorf("HandleClientFailure() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr && err != nil { + return + } + + var failureResp ClientFailureBecknResponse + if err := json.Unmarshal(resp, &failureResp); err != nil { + t.Errorf("Failed to unmarshal response: %v", err) + return + } + + if failureResp.Error.Code != tt.wantErrCode { + t.Errorf("HandleClientFailure() error code = %v, want %v", failureResp.Error.Code, tt.wantErrCode) + } + + if failureResp.Error.Message != tt.wantErrMsg { + t.Errorf("HandleClientFailure() error message = %v, want %v", failureResp.Error.Message, tt.wantErrMsg) + } + + var origReq BecknRequest + if err := json.Unmarshal([]byte(tt.requestBody), &origReq); err == nil { + if !compareContexts(failureResp.Context, origReq.Context) { + t.Errorf("HandleClientFailure() context not preserved, got = %v, want %v", failureResp.Context, origReq.Context) + } + } + }) + } +} + +func TestErrorMap(t *testing.T) { + + expectedTypes := []ErrorType{ + SchemaValidationErrorType, + InvalidRequestErrorType, + } + + for _, tp := range expectedTypes { + if _, exists := errorMap[tp]; !exists { + t.Errorf("ErrorType %v not found in errorMap", tp) + } + } + + if DefaultError.Code != "500" || DefaultError.Message != "Internal server error" { + t.Errorf("DefaultError not set correctly, got code=%v, message=%v", DefaultError.Code, DefaultError.Message) + } +} + +func compareContexts(c1, c2 map[string]interface{}) bool { + + if c1 == nil && c2 == nil { + return true + } + + if c1 == nil && len(c2) == 0 || c2 == nil && len(c1) == 0 { + return true + } + + return reflect.DeepEqual(c1, c2) +} diff --git a/shared/plugin/definition/signVerifier.go b/shared/plugin/definition/signVerifier.go new file mode 100644 index 0000000..fe36358 --- /dev/null +++ b/shared/plugin/definition/signVerifier.go @@ -0,0 +1,22 @@ +package definition + +import "context" + +// Verifier defines the method for verifying signatures. +type Verifier interface { + // Verify checks the validity of the signature for the given body. + Verify(ctx context.Context, body []byte, header []byte, publicKeyBase64 string) (bool, error) + Close() error // Close for releasing resources +} + +// VerifierProvider initializes a new Verifier instance with the given config. +type VerifierProvider interface { + // New creates a new Verifier instance based on the provided config. + New(ctx context.Context, config map[string]string) (Verifier, func() error, error) +} + +// PublicKeyManager is the interface for key management plugin. +type PublicKeyManager interface { + // PublicKey retrieves the public key for the given subscriberID and keyID. + PublicKey(ctx context.Context, subscriberID string, keyID string) (string, error) +} diff --git a/shared/plugin/definition/signer.go b/shared/plugin/definition/signer.go new file mode 100644 index 0000000..84db5f5 --- /dev/null +++ b/shared/plugin/definition/signer.go @@ -0,0 +1,24 @@ +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) + Close() error // Close for releasing resources +} + +// 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) +} + +// PrivateKeyManager is the interface for key management plugin. +type PrivateKeyManager interface { + // PrivateKey retrieves the private key for the given subscriberID and keyID. + PrivateKey(ctx context.Context, subscriberID string, keyID string) (string, error) +} diff --git a/shared/plugin/implementation/signVerifier/cmd/plugin.go b/shared/plugin/implementation/signVerifier/cmd/plugin.go new file mode 100644 index 0000000..1e4fb06 --- /dev/null +++ b/shared/plugin/implementation/signVerifier/cmd/plugin.go @@ -0,0 +1,26 @@ +package main + +import ( + "context" + "errors" + + "github.com/beckn/beckn-onix/shared/plugin/definition" + + plugin "github.com/beckn/beckn-onix/shared/plugin/definition" + verifier "github.com/beckn/beckn-onix/shared/plugin/implementation/signVerifier" +) + +// VerifierProvider provides instances of Verifier. +type VerifierProvider struct{} + +// New initializes a new Verifier instance. +func (vp VerifierProvider) New(ctx context.Context, config map[string]string) (plugin.Verifier, func() error, error) { + if ctx == nil { + return nil, nil, errors.New("context cannot be nil") + } + + return verifier.New(ctx, &verifier.Config{}) +} + +// Provider is the exported symbol that the plugin manager will look for. +var Provider definition.VerifierProvider = VerifierProvider{} diff --git a/shared/plugin/implementation/signVerifier/cmd/plugin_test.go b/shared/plugin/implementation/signVerifier/cmd/plugin_test.go new file mode 100644 index 0000000..85caee5 --- /dev/null +++ b/shared/plugin/implementation/signVerifier/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 := VerifierProvider{} + + 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 := VerifierProvider{} + + 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/shared/plugin/implementation/signVerifier/signVerifier.go b/shared/plugin/implementation/signVerifier/signVerifier.go new file mode 100644 index 0000000..963d137 --- /dev/null +++ b/shared/plugin/implementation/signVerifier/signVerifier.go @@ -0,0 +1,120 @@ +package verifier + +import ( + "context" + "crypto/ed25519" + "encoding/base64" + "fmt" + "strconv" + "strings" + "time" + + "golang.org/x/crypto/blake2b" +) + +// Config struct for Verifier. +type Config struct { +} + +// Verifier implements the Verifier interface. +type Verifier struct { + config *Config +} + +// New creates a new Verifier instance. +func New(ctx context.Context, config *Config) (*Verifier, func() error, error) { + v := &Verifier{config: config} + + return v, v.Close, nil +} + +// Verify checks the signature for the given payload and public key. +func (v *Verifier) Verify(ctx context.Context, body []byte, header []byte, publicKeyBase64 string) (bool, error) { + createdTimestamp, expiredTimestamp, signature, err := parseAuthHeader(string(header)) + if err != nil { + // TODO: Return appropriate error code when Error Code Handling Module is ready + return false, fmt.Errorf("error parsing header: %w", err) + } + + signatureBytes, err := base64.StdEncoding.DecodeString(signature) + if err != nil { + // TODO: Return appropriate error code when Error Code Handling Module is ready + return false, fmt.Errorf("error decoding signature: %w", err) + } + + currentTime := time.Now().Unix() + if createdTimestamp > currentTime || currentTime > expiredTimestamp { + // TODO: Return appropriate error code when Error Code Handling Module is ready + return false, 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 { + // TODO: Return appropriate error code when Error Code Handling Module is ready + return false, fmt.Errorf("error decoding public key: %w", err) + } + + if !ed25519.Verify(ed25519.PublicKey(decodedPublicKey), []byte(signingString), signatureBytes) { + // TODO: Return appropriate error code when Error Code Handling Module is ready + return false, fmt.Errorf("signature verification failed") + } + + return true, 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 { + // TODO: Return appropriate error code when Error Code Handling Module is ready + return 0, 0, "", 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, "", 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) +} + +// Close releases resources (mock implementation returning nil). +func (v *Verifier) Close() error { + return nil +} diff --git a/shared/plugin/implementation/signVerifier/signVerifier_test.go b/shared/plugin/implementation/signVerifier/signVerifier_test.go new file mode 100644 index 0000000..36da03a --- /dev/null +++ b/shared/plugin/implementation/signVerifier/signVerifier_test.go @@ -0,0 +1,153 @@ +package verifier + +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{}) + valid, err := verifier.Verify(context.Background(), tt.body, []byte(header), publicKeyBase64) + + if err != nil { + t.Fatalf("Expected no error, but got: %v", err) + } + if !valid { + t.Fatal("Expected signature verification to succeed") + } + 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{}) + valid, err := verifier.Verify(context.Background(), tt.body, []byte(tt.header), tt.pubKey) + + if err == nil { + t.Fatal("Expected an error but got none") + } + if valid { + t.Fatal("Expected verification to fail") + } + 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/shared/plugin/implementation/signer/cmd/plugin.go b/shared/plugin/implementation/signer/cmd/plugin.go new file mode 100644 index 0000000..854ecbe --- /dev/null +++ b/shared/plugin/implementation/signer/cmd/plugin.go @@ -0,0 +1,24 @@ +package main + +import ( + "context" + "errors" + + "github.com/beckn/beckn-onix/shared/plugin/definition" + "github.com/beckn/beckn-onix/shared/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 definition.SignerProvider = SignerProvider{} diff --git a/shared/plugin/implementation/signer/cmd/plugin_test.go b/shared/plugin/implementation/signer/cmd/plugin_test.go new file mode 100644 index 0000000..e4730d5 --- /dev/null +++ b/shared/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/shared/plugin/implementation/signer/signer.go b/shared/plugin/implementation/signer/signer.go new file mode 100644 index 0000000..c1f2af9 --- /dev/null +++ b/shared/plugin/implementation/signer/signer.go @@ -0,0 +1,77 @@ +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, s.Close, 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.PrivateKeySize { + return nil, errors.New("invalid private key length") + } + + privateKey := ed25519.PrivateKey(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 +} + +// Close releases resources (mock implementation returning nil). +func (s *Signer) Close() error { + return nil +} diff --git a/shared/plugin/implementation/signer/signer_test.go b/shared/plugin/implementation/signer/signer_test.go new file mode 100644 index 0000000..6a25da1 --- /dev/null +++ b/shared/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), 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 private key 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/shared/plugin/manager.go b/shared/plugin/manager.go new file mode 100644 index 0000000..e31fc98 --- /dev/null +++ b/shared/plugin/manager.go @@ -0,0 +1,108 @@ +package plugin + +import ( + "context" + "fmt" + "path/filepath" + "plugin" + "strings" + + "github.com/beckn/beckn-onix/shared/plugin/definition" +) + +// Config represents the plugin manager configuration. +type Config struct { + Root string `yaml:"root"` + Signer PluginConfig `yaml:"signer"` + Verifier PluginConfig `yaml:"verifier"` +} + +// PluginConfig represents configuration details for a plugin. +type PluginConfig struct { + ID string `yaml:"id"` + Config map[string]string `yaml:"config"` +} + +// Manager handles dynamic plugin loading and management. +type Manager struct { + sp definition.SignerProvider + vp definition.VerifierProvider + cfg *Config +} + +// NewManager initializes a new Manager with the given configuration file. +func NewManager(ctx context.Context, cfg *Config) (*Manager, error) { + if cfg == nil { + return nil, fmt.Errorf("configuration cannot be nil") + } + + // Load signer plugin + sp, err := provider[definition.SignerProvider](cfg.Root, cfg.Signer.ID) + if err != nil { + return nil, fmt.Errorf("failed to load signer plugin: %w", err) + } + + // Load verifier plugin + vp, err := provider[definition.VerifierProvider](cfg.Root, cfg.Verifier.ID) + if err != nil { + return nil, fmt.Errorf("failed to load Verifier plugin: %w", err) + } + + return &Manager{sp: sp, vp: vp, cfg: cfg}, nil +} + +// provider loads a plugin dynamically and retrieves its provider instance. +func provider[T any](root, id string) (T, error) { + var zero T + if len(strings.TrimSpace(id)) == 0 { + return zero, nil + } + + p, err := plugin.Open(pluginPath(root, id)) + if err != nil { + return zero, fmt.Errorf("failed to open plugin %s: %w", id, err) + } + + symbol, err := p.Lookup("Provider") + if err != nil { + return zero, fmt.Errorf("failed to find Provider symbol in plugin %s: %w", id, err) + } + + prov, ok := symbol.(*T) + if !ok { + return zero, fmt.Errorf("failed to cast Provider for %s", id) + } + + return *prov, nil +} + +// pluginPath constructs the path to the plugin shared object file. +func pluginPath(root, id string) string { + return filepath.Join(root, id+".so") +} + +// Signer retrieves the signing plugin instance. +func (m *Manager) Signer(ctx context.Context) (definition.Signer, func() error, error) { + if m.sp == nil { + return nil, nil, fmt.Errorf("signing plugin provider not loaded") + } + + signer, close, err := m.sp.New(ctx, m.cfg.Signer.Config) + if err != nil { + return nil, nil, fmt.Errorf("failed to initialize signer: %w", err) + } + return signer, close, nil +} + +// Verifier retrieves the verification plugin instance. +func (m *Manager) Verifier(ctx context.Context) (definition.Verifier, func() error, error) { + if m.vp == nil { + return nil, nil, fmt.Errorf("Verifier plugin provider not loaded") + } + + Verifier, close, err := m.vp.New(ctx, m.cfg.Verifier.Config) + if err != nil { + return nil, nil, fmt.Errorf("failed to initialize Verifier: %w", err) + } + return Verifier, close, nil +}