From 011edd0c2acb53f0f04497ae9b4cb7345d5fc49d Mon Sep 17 00:00:00 2001 From: Douglas-Lee Date: Mon, 17 Nov 2025 14:13:17 +0800 Subject: [PATCH 1/2] refactor(entities/source): refactor source object --- admin/api/sources.go | 6 ++ db/entities/source.go | 35 +++++++--- .../1762423418_source_config.down.sql | 11 ++++ db/migrations/1762423418_source_config.up.sql | 8 +++ .../function/webhookx-function-sample.yml | 15 +++-- openapi.yml | 65 ++++++++++++------- proxy/gateway.go | 12 ++-- test/README.md | 23 +++++++ test/admin/sources_test.go | 38 ++++++----- test/cmd/admin_test.go | 10 +-- test/cmd/db_test.go | 8 ++- test/cmd/testdata/dump.yml | 11 ++-- test/declarative/declarative_test.go | 17 +++-- test/fixtures/webhookx.yml | 15 +++-- test/helper/factory/factory.go | 17 +---- test/proxy/ingest_test.go | 14 ++-- test/schema/schema_test.go | 62 ++++++++++++------ utils/validate.go | 3 +- utils/validate_test.go | 2 +- webhookx.sample.yml | 15 +++-- 20 files changed, 252 insertions(+), 135 deletions(-) create mode 100644 db/migrations/1762423418_source_config.down.sql create mode 100644 db/migrations/1762423418_source_config.up.sql diff --git a/admin/api/sources.go b/admin/api/sources.go index e713f274..7cddcee1 100644 --- a/admin/api/sources.go +++ b/admin/api/sources.go @@ -40,6 +40,12 @@ func (api *API) CreateSource(w http.ResponseWriter, r *http.Request) { return } + if source.Type == "http" { + if source.Config.HTTP.Path == "" { + source.Config.HTTP.Path = "/" + utils.UUIDShort() + } + } + source.WorkspaceId = ucontext.GetWorkspaceID(r.Context()) err := api.db.SourcesWS.Insert(r.Context(), &source) api.assert(err) diff --git a/db/entities/source.go b/db/entities/source.go index e384a7d3..173e31ed 100644 --- a/db/entities/source.go +++ b/db/entities/source.go @@ -19,16 +19,33 @@ func (m CustomResponse) Value() (driver.Value, error) { return json.Marshal(m) } +type SourceConfig struct { + HTTP HttpSourceConfig `json:"http"` +} + +func (m *SourceConfig) Scan(src interface{}) error { + return json.Unmarshal(src.([]byte), m) +} + +func (m SourceConfig) Value() (driver.Value, error) { + return json.Marshal(m) +} + +type HttpSourceConfig struct { + Path string `json:"path"` + Methods Strings `json:"methods"` + Response *CustomResponse `json:"response"` +} + type Source struct { - ID string `json:"id" db:"id"` - Name *string `json:"name" db:"name"` - Enabled bool `json:"enabled" db:"enabled"` - Path string `json:"path" db:"path"` - Methods Strings `json:"methods" db:"methods"` - Async bool `json:"async" db:"async"` - Response *CustomResponse `json:"response" db:"response"` - Metadata Metadata `json:"metadata" db:"metadata"` - RateLimit *RateLimit `json:"rate_limit" yaml:"rate_limit" db:"rate_limit"` + ID string `json:"id" db:"id"` + Name *string `json:"name" db:"name"` + Enabled bool `json:"enabled" db:"enabled"` + Type string `json:"type" db:"type"` + Config SourceConfig `json:"config" db:"config"` + Async bool `json:"async" db:"async"` + Metadata Metadata `json:"metadata" db:"metadata"` + RateLimit *RateLimit `json:"rate_limit" yaml:"rate_limit" db:"rate_limit"` BaseModel `yaml:"-"` } diff --git a/db/migrations/1762423418_source_config.down.sql b/db/migrations/1762423418_source_config.down.sql new file mode 100644 index 00000000..0721e85a --- /dev/null +++ b/db/migrations/1762423418_source_config.down.sql @@ -0,0 +1,11 @@ +ALTER TABLE IF EXISTS ONLY "sources" ADD COLUMN IF NOT EXISTS "path" TEXT; +ALTER TABLE IF EXISTS ONLY "sources" ADD COLUMN IF NOT EXISTS "methods" TEXT[]; +ALTER TABLE IF EXISTS ONLY "sources" ADD COLUMN IF NOT EXISTS "response" JSONB; + +UPDATE sources SET + "path" = (config->'http'->>'path'), + "methods" = ARRAY(SELECT jsonb_array_elements_text(config->'http'->'methods')), + "response" = (config->'http'->'response'); + +ALTER TABLE IF EXISTS ONLY "sources" DROP COLUMN IF EXISTS "type"; +ALTER TABLE IF EXISTS ONLY "sources" DROP COLUMN IF EXISTS "config"; diff --git a/db/migrations/1762423418_source_config.up.sql b/db/migrations/1762423418_source_config.up.sql new file mode 100644 index 00000000..5606d495 --- /dev/null +++ b/db/migrations/1762423418_source_config.up.sql @@ -0,0 +1,8 @@ +ALTER TABLE IF EXISTS ONLY "sources" ADD COLUMN IF NOT EXISTS "type" varchar(20); +ALTER TABLE IF EXISTS ONLY "sources" ADD COLUMN IF NOT EXISTS "config" JSONB NOT NULL DEFAULT '{}'::jsonb; + +UPDATE sources SET "type" = 'http', "config" = jsonb_build_object('http', jsonb_build_object('methods', methods, 'path', path, 'response', response)); + +ALTER TABLE IF EXISTS ONLY "sources" DROP COLUMN IF EXISTS "path"; +ALTER TABLE IF EXISTS ONLY "sources" DROP COLUMN IF EXISTS "methods"; +ALTER TABLE IF EXISTS ONLY "sources" DROP COLUMN IF EXISTS "response"; diff --git a/examples/function/webhookx-function-sample.yml b/examples/function/webhookx-function-sample.yml index 0edcd63c..3fb1005e 100644 --- a/examples/function/webhookx-function-sample.yml +++ b/examples/function/webhookx-function-sample.yml @@ -14,12 +14,15 @@ endpoints: sources: - name: github-source - path: /github - methods: [ "POST" ] - response: - code: 200 - content_type: application/json - body: '{"message": "OK"}' + type: http + config: + http: + path: /github + methods: [ "POST" ] + response: + code: 200 + content_type: application/json + body: '{"message": "OK"}' plugins: - name: function config: diff --git a/openapi.yml b/openapi.yml index 6a2963d4..3e7926e2 100644 --- a/openapi.yml +++ b/openapi.yml @@ -877,33 +877,21 @@ components: enabled: type: boolean default: true - path: + type: type: string - methods: - type: array - items: - type: string - enum: [ GET, POST, PUT, DELETE, PATCH ] - minItems: 1 + enum: [ "http" ] + default: http + config: + type: object + properties: + http: + $ref: "#/components/schemas/HTTPSourceConfig" + required: + - http async: type: boolean description: "Whether to ingest events asynchronously through the queue" default: false - response: - type: object - nullable: true - properties: - code: - type: integer - minimum: 200 - maximum: 599 - content_type: - type: string - body: - type: string - required: - - code - - content_type metadata: $ref: "#/components/schemas/Metadata" rate_limit: @@ -915,8 +903,7 @@ components: type: integer readOnly: true required: - - path - - methods + - config Plugin: type: object @@ -1049,3 +1036,33 @@ components: type: string format: jsonschema maxLength: 1048576 + + HTTPSourceConfig: + type: object + default: + methods: [ "POST" ] + properties: + path: + type: string + methods: + type: array + items: + type: string + enum: [ GET, POST, PUT, DELETE, PATCH ] + minItems: 1 + default: [ "POST" ] + response: + type: object + nullable: true + properties: + code: + type: integer + minimum: 200 + maximum: 599 + content_type: + type: string + body: + type: string + required: + - code + - content_type diff --git a/proxy/gateway.go b/proxy/gateway.go index 8f5d8cb7..8d0c0636 100644 --- a/proxy/gateway.go +++ b/proxy/gateway.go @@ -169,8 +169,8 @@ func (gw *Gateway) buildRouter(version string) { routes := make([]*router.Route, 0) for _, source := range sources { route := router.Route{ - Paths: []string{source.Path}, - Methods: source.Methods, + Paths: []string{source.Config.HTTP.Path}, + Methods: source.Config.HTTP.Methods, Handler: source, } routes = append(routes, &route) @@ -204,7 +204,7 @@ func (gw *Gateway) handle(w http.ResponseWriter, r *http.Request) bool { span.SetAttributes(attribute.String("source.name", utils.PointerValue(source.Name))) span.SetAttributes(attribute.String("source.workspace_id", source.WorkspaceId)) span.SetAttributes(attribute.Bool("source.async", source.Async)) - span.SetAttributes(semconv.HTTPRoute(source.Path)) + span.SetAttributes(semconv.HTTPRoute(source.Config.HTTP.Path)) defer span.End() ctx = tracingCtx } @@ -306,9 +306,9 @@ func (gw *Gateway) handle(w http.ResponseWriter, r *http.Request) bool { headers[constants.HeaderEventId] = event.ID } - if source.Response != nil { - headers["Content-Type"] = source.Response.ContentType - exit(w, source.Response.Code, source.Response.Body, headers) + if source.Config.HTTP.Response != nil { + headers["Content-Type"] = source.Config.HTTP.Response.ContentType + exit(w, source.Config.HTTP.Response.Code, source.Config.HTTP.Response.Body, headers) return true } diff --git a/test/README.md b/test/README.md index fa02d098..c892b7a2 100644 --- a/test/README.md +++ b/test/README.md @@ -12,3 +12,26 @@ make deps ```shell make test-integration ``` + + +# How-To + +#### How to run specific tests? + +`FIt`, `FContext`. + +``` +Context("some specs you're debugging", func() { + It("might be failing", func() { ... }) + FIt("might also be failing", func() { ... }) +}) + +``` + +``` +ginkgo +``` + +This will run only test "might also be failing", skip the rest. + +See https://onsi.github.io/ginkgo/#focused-specs. diff --git a/test/admin/sources_test.go b/test/admin/sources_test.go index 24c1fa05..5bafe606 100644 --- a/test/admin/sources_test.go +++ b/test/admin/sources_test.go @@ -38,10 +38,7 @@ var _ = Describe("/sources", Ordered, func() { Context("POST", func() { It("creates a source", func() { resp, err := adminClient.R(). - SetBody(map[string]interface{}{ - "path": "/v1", - "methods": []string{"POST"}, - }). + SetBody(`{ "type": "http", "config": { "http": { "path": "" } }}`). SetResult(entities.Source{}). Post("/workspaces/default/sources") assert.Nil(GinkgoT(), err) @@ -51,10 +48,10 @@ var _ = Describe("/sources", Ordered, func() { result := resp.Result().(*entities.Source) assert.NotNil(GinkgoT(), result.ID) assert.Equal(GinkgoT(), true, result.Enabled) - assert.Equal(GinkgoT(), "/v1", result.Path) - assert.EqualValues(GinkgoT(), []string{"POST"}, result.Methods) + assert.True(GinkgoT(), len(result.Config.HTTP.Path) > 0) // auto generate path + assert.EqualValues(GinkgoT(), []string{"POST"}, result.Config.HTTP.Methods) assert.Equal(GinkgoT(), false, result.Async) - assert.True(GinkgoT(), nil == result.Response) + assert.True(GinkgoT(), nil == result.Config.HTTP.Response) e, err := db.Sources.Get(context.TODO(), result.ID) assert.Nil(GinkgoT(), err) @@ -70,7 +67,7 @@ var _ = Describe("/sources", Ordered, func() { assert.Nil(GinkgoT(), err) assert.Equal(GinkgoT(), 400, resp.StatusCode()) assert.Equal(GinkgoT(), - `{"message":"Request Validation","error":{"message":"request validation","fields":{"methods":"required field missing","path":"required field missing"}}}`, + `{"message":"Request Validation","error":{"message":"request validation","fields":{"config":"required field missing"}}}`, string(resp.Body())) }) }) @@ -166,14 +163,19 @@ var _ = Describe("/sources", Ordered, func() { It("updates by id", func() { resp, err := adminClient.R(). SetBody(map[string]interface{}{ - "path": "/v1", - "methods": []string{"GET", "POST", "PUT", "DELETE"}, - "async": true, - "response": map[string]interface{}{ - "code": 200, - "content_type": "text/plain", - "body": "OK", + "type": "http", + "config": map[string]interface{}{ + "http": map[string]interface{}{ + "path": "/v1", + "methods": []string{"GET", "POST", "PUT", "DELETE"}, + "response": map[string]interface{}{ + "code": 200, + "content_type": "text/plain", + "body": "OK", + }, + }, }, + "async": true, }). SetResult(entities.Source{}). Put("/workspaces/default/sources/" + entity.ID) @@ -183,14 +185,14 @@ var _ = Describe("/sources", Ordered, func() { result := resp.Result().(*entities.Source) assert.Equal(GinkgoT(), entity.ID, result.ID) - assert.Equal(GinkgoT(), "/v1", result.Path) - assert.EqualValues(GinkgoT(), []string{"GET", "POST", "PUT", "DELETE"}, result.Methods) + assert.Equal(GinkgoT(), "/v1", result.Config.HTTP.Path) + assert.EqualValues(GinkgoT(), []string{"GET", "POST", "PUT", "DELETE"}, result.Config.HTTP.Methods) assert.Equal(GinkgoT(), true, result.Async) assert.EqualValues(GinkgoT(), &entities.CustomResponse{ Code: 200, ContentType: "text/plain", Body: "OK", - }, result.Response) + }, result.Config.HTTP.Response) }) }) diff --git a/test/cmd/admin_test.go b/test/cmd/admin_test.go index b4fb78fc..f2d6590a 100644 --- a/test/cmd/admin_test.go +++ b/test/cmd/admin_test.go @@ -87,11 +87,11 @@ var _ = Describe("admin", Ordered, func() { assert.NoError(GinkgoT(), err) assert.Equal(GinkgoT(), "default-source", *source.Name) assert.Equal(GinkgoT(), true, source.Enabled) - assert.Equal(GinkgoT(), "/", source.Path) - assert.Equal(GinkgoT(), []string{"POST"}, []string(source.Methods)) - assert.Equal(GinkgoT(), 200, source.Response.Code) - assert.Equal(GinkgoT(), "application/json", source.Response.ContentType) - assert.Equal(GinkgoT(), `{"message": "OK"}`, source.Response.Body) + assert.Equal(GinkgoT(), "/", source.Config.HTTP.Path) + assert.Equal(GinkgoT(), []string{"POST"}, []string(source.Config.HTTP.Methods)) + assert.Equal(GinkgoT(), 200, source.Config.HTTP.Response.Code) + assert.Equal(GinkgoT(), "application/json", source.Config.HTTP.Response.ContentType) + assert.Equal(GinkgoT(), `{"message": "OK"}`, source.Config.HTTP.Response.Body) plugins, err := db.Plugins.ListEndpointPlugin(context.TODO(), endpoint.ID) assert.NoError(GinkgoT(), err) diff --git a/test/cmd/db_test.go b/test/cmd/db_test.go index 6b37e70f..aad1b83f 100644 --- a/test/cmd/db_test.go +++ b/test/cmd/db_test.go @@ -19,11 +19,12 @@ var statusOutputInit = `1 init (⏳ pending) 9 timestamp (⏳ pending) 10 ratelimit (⏳ pending) 11 event_unique_id (⏳ pending) +1762423418 source_config (⏳ pending) Summary: Current version: 0 Dirty: false Executed: 0 - Pending: 11 + Pending: 12 ` var statusOutputDone = `1 init (✅ executed) @@ -37,10 +38,11 @@ var statusOutputDone = `1 init (✅ executed) 9 timestamp (✅ executed) 10 ratelimit (✅ executed) 11 event_unique_id (✅ executed) +1762423418 source_config (✅ executed) Summary: - Current version: 11 + Current version: 1762423418 Dirty: false - Executed: 11 + Executed: 12 Pending: 0 ` diff --git a/test/cmd/testdata/dump.yml b/test/cmd/testdata/dump.yml index ad27ddd8..2fe23d16 100644 --- a/test/cmd/testdata/dump.yml +++ b/test/cmd/testdata/dump.yml @@ -34,11 +34,14 @@ sources: - id: 2q6ItgNdNEIvoJ2wffn5G5j8HYC name: null enabled: true - path: / - methods: - - POST + type: http + config: + http: + path: / + methods: + - POST + response: null async: false - response: null metadata: k: v rate_limit: null diff --git a/test/declarative/declarative_test.go b/test/declarative/declarative_test.go index 9d5ee2c0..1b1adb00 100644 --- a/test/declarative/declarative_test.go +++ b/test/declarative/declarative_test.go @@ -15,13 +15,14 @@ import ( var ( malformedYAML = ` -webhookx is coolest! +webhookx is awesome 👻! ` invalidYAML = ` sources: - name: default-source - path: / - enabled: ok + config: + path: / + enabled: ok ` invalidEndpointYAML = ` @@ -44,8 +45,9 @@ endpoints: invalidSourcePluginJSONSchemaConfigYAML = ` sources: - name: default-source - path: / - methods: ["POST"] + config: + path: / + methods: ["POST"] plugins: - name: "jsonschema-validator" config: @@ -60,8 +62,9 @@ sources: invalidSourcePluginJSONSchemaJSONYAML = ` sources: - name: default-source - path: / - methods: ["POST"] + config: + path: / + methods: ["POST"] plugins: - name: "jsonschema-validator" config: diff --git a/test/fixtures/webhookx.yml b/test/fixtures/webhookx.yml index a52322f2..a73df0b3 100644 --- a/test/fixtures/webhookx.yml +++ b/test/fixtures/webhookx.yml @@ -27,12 +27,15 @@ endpoints: sources: - name: default-source - path: / - methods: [ "POST" ] - response: - code: 200 - content_type: application/json - body: '{"message": "OK"}' + type: http + config: + http: + path: / + methods: [ "POST" ] + response: + code: 200 + content_type: application/json + body: '{"message": "OK"}' plugins: - name: function config: diff --git a/test/helper/factory/factory.go b/test/helper/factory/factory.go index fdfdb3e6..ddf79079 100644 --- a/test/helper/factory/factory.go +++ b/test/helper/factory/factory.go @@ -87,8 +87,9 @@ func defaultSource() entities.Source { SetDefault(entities.LookupSchema("Source"), &entity) entity.ID = utils.KSUID() - entity.Path = "/" - entity.Methods = []string{"POST"} + entity.Type = "http" + entity.Config.HTTP.Path = "/" + entity.Config.HTTP.Methods = []string{"POST"} return entity } @@ -107,24 +108,12 @@ func WithSourceAsync(async bool) SourceOption { } } -func WithSourcePath(path string) SourceOption { - return func(e *entities.Source) { - e.Path = path - } -} - func WithSourceMetadata(metadata map[string]string) SourceOption { return func(e *entities.Source) { e.Metadata = metadata } } -func WithSourceResponse(response *entities.CustomResponse) SourceOption { - return func(e *entities.Source) { - e.Response = response - } -} - func Source(opts ...SourceOption) entities.Source { e := defaultSource() for _, opt := range opts { diff --git a/test/proxy/ingest_test.go b/test/proxy/ingest_test.go index f9e3377b..92aaa478 100644 --- a/test/proxy/ingest_test.go +++ b/test/proxy/ingest_test.go @@ -28,12 +28,14 @@ var _ = Describe("ingest", Ordered, func() { Sources: []*entities.Source{ factory.SourceP(), factory.SourceP( - factory.WithSourcePath("/custom-response"), - factory.WithSourceResponse(&entities.CustomResponse{ - Code: 201, - ContentType: "application/xml", - Body: "ok", - })), + func(o *entities.Source) { + o.Config.HTTP.Path = "/custom-response" + o.Config.HTTP.Response = &entities.CustomResponse{ + Code: 201, + ContentType: "application/xml", + Body: "ok", + } + }), }, } entitiesConfig.Sources[0].Async = true diff --git a/test/schema/schema_test.go b/test/schema/schema_test.go index c1e8c245..7cbc45a9 100644 --- a/test/schema/schema_test.go +++ b/test/schema/schema_test.go @@ -113,33 +113,52 @@ var _ = Describe("schemas", Ordered, func() { feildsJSON string }{ { - name: "methods is emtpy list", + name: "missing requires fields", + data: map[string]interface{}{}, + feildsJSON: `{"config":"required field missing"}`, + }, + { + name: "config.http.methods is empty", data: map[string]interface{}{ - "path": "/", - "methods": []interface{}{}, + "type": "http", + "config": map[string]interface{}{ + "http": map[string]interface{}{ + "path": "/", + "methods": []interface{}{}, + }, + }, }, - feildsJSON: `{"methods":"minimum number of items is 1"}`, + feildsJSON: `{"config":{"http":{"methods":"minimum number of items is 1"}}}`, }, { - name: "methods element is invalid", + name: "config.http.methods element is invalid", data: map[string]interface{}{ - "path": "/", - "methods": []interface{}{"test"}, + "type": "http", + "config": map[string]interface{}{ + "http": map[string]interface{}{ + "path": "/", + "methods": []interface{}{"unknown"}, + }, + }, }, - feildsJSON: `{"methods":["value is not one of the allowed values [\"GET\",\"POST\",\"PUT\",\"DELETE\",\"PATCH\"]"]}`, + feildsJSON: `{"config":{"http":{"methods":["value is not one of the allowed values [\"GET\",\"POST\",\"PUT\",\"DELETE\",\"PATCH\"]"]}}}`, }, - { - name: "response.code is invalid", + name: "config.http.response.code is invalid", data: map[string]interface{}{ - "path": "/", - "methods": []interface{}{"POST"}, - "response": map[string]interface{}{ - "code": 600, - "content_type": "application/json", + "type": "http", + "config": map[string]interface{}{ + "http": map[string]interface{}{ + "path": "/", + "methods": []interface{}{"POST"}, + "response": map[string]interface{}{ + "code": 600, + "content_type": "application/json", + }, + }, }, }, - feildsJSON: `{"response":{"code":"number must be at most 599"}}`, + feildsJSON: `{"config":{"http":{"response":{"code":"number must be at most 599"}}}}`, }, } for _, test := range tests { @@ -177,13 +196,18 @@ var _ = Describe("schemas", Ordered, func() { "sources": []interface{}{ map[string]interface{}{}, map[string]interface{}{ - "path": "/", - "methods": []interface{}{"POST"}, + "type": "http", + "config": map[string]interface{}{ + "http": map[string]interface{}{ + "path": "/", + "methods": []interface{}{"POST"}, + }, + }, }, map[string]interface{}{}, }, }, - feildsJSON: `{"endpoints":[{"request":{"url":"required field missing"}},null,{"request":{"url":"required field missing"}}],"sources":[{"methods":"required field missing","path":"required field missing"},null,{"methods":"required field missing","path":"required field missing"}]}`, + feildsJSON: `{"endpoints":[{"request":{"url":"required field missing"}},null,{"request":{"url":"required field missing"}}],"sources":[{"config":"required field missing"},null,{"config":"required field missing"}]}`, }, } for _, test := range tests { diff --git a/utils/validate.go b/utils/validate.go index 2953c3b1..91d06c79 100644 --- a/utils/validate.go +++ b/utils/validate.go @@ -30,7 +30,8 @@ func init() { return "required field missing" }) RegisterFormatter("oneof", func(fe validator.FieldError) string { - return fmt.Sprintf("invalid value: %s", fe.Value()) + enums := strings.Split(fe.Param(), " ") + return fmt.Sprintf("value must be one of: [%s]", strings.Join(enums, ", ")) }) RegisterFormatter("gt", func(fe validator.FieldError) string { return fmt.Sprintf("value must be > %s", fe.Param()) diff --git a/utils/validate_test.go b/utils/validate_test.go index d070dfbb..1feb6a2f 100644 --- a/utils/validate_test.go +++ b/utils/validate_test.go @@ -44,7 +44,7 @@ func TestValidate(t *testing.T) { "Age": "value must be >= 0", "Name": "required field missing", "Nest": { - "Gender": "invalid value: x", + "Gender": "value must be one of: [male, female]", "NestB": { "Timeout": "value must be > 0" } diff --git a/webhookx.sample.yml b/webhookx.sample.yml index 749b74b6..317843fe 100644 --- a/webhookx.sample.yml +++ b/webhookx.sample.yml @@ -24,12 +24,15 @@ endpoints: sources: - name: default-source - path: / - methods: [ "POST" ] - response: - code: 200 - content_type: application/json - body: '{"message": "OK"}' + type: http + config: + http: + path: / + methods: [ "POST" ] + response: + code: 200 + content_type: application/json + body: '{"message": "OK"}' plugins: - name: "jsonschema-validator" config: From 179c4990548fe7fe9c0defc137a5816c5951e2fe Mon Sep 17 00:00:00 2001 From: Douglas-Lee Date: Mon, 17 Nov 2025 15:00:53 +0800 Subject: [PATCH 2/2] feat(plugins): add plugins --- openapi.yml | 66 +++++++++++ plugins/basic-auth/plugin.go | 38 ++++++ plugins/hmac-auth/plugin.go | 93 +++++++++++++++ plugins/key-auth/plugin.go | 59 +++++++++ plugins/plugins.go | 12 ++ test/admin/plugins_test.go | 93 ++++++++++++++- test/plugins/basic_auth_test.go | 110 +++++++++++++++++ test/plugins/hmac_auth_test.go | 204 ++++++++++++++++++++++++++++++++ test/plugins/key_auth_test.go | 97 +++++++++++++++ 9 files changed, 771 insertions(+), 1 deletion(-) create mode 100644 plugins/basic-auth/plugin.go create mode 100644 plugins/hmac-auth/plugin.go create mode 100644 plugins/key-auth/plugin.go create mode 100644 test/plugins/basic_auth_test.go create mode 100644 test/plugins/hmac_auth_test.go create mode 100644 test/plugins/key_auth_test.go diff --git a/openapi.yml b/openapi.yml index 3e7926e2..86c73931 100644 --- a/openapi.yml +++ b/openapi.yml @@ -1037,6 +1037,72 @@ components: format: jsonschema maxLength: 1048576 + BasicAuthPluginConfiguration: + description: "The basic auth plugin configuration" + type: object + properties: + username: + description: "The username used for Basic Authentication" + type: string + password: + description: "The password used for Basic Authentication" + type: string + required: + - username + - password + + KeyAuthPluginConfiguration: + description: "The key-auth plugin configuration" + type: object + properties: + param_name: + description: "The parameter name of api key." + type: string + minLength: 1 + param_locations: + description: "The locations where the api key can be provided." + type: array + uniqueItems: true + minItems: 1 + items: + type: string + enum: [ "header", "query" ] + key: + description: "The api key used for authentication" + type: string + minLength: 1 + required: + - param_name + - param_locations + - key + + HmacAuthPluginConfiguration: + description: "The hmac-auth plugin configuration" + type: object + properties: + hash: + description: "The hash algorithm used to generate the HMAC signature." + type: string + enum: [ "md5", "sha-1", "sha-256", "sha-512" ] + default: "sha-256" + encoding: + description: "The encoding format of the generated signature." + type: string + enum: [ "hex", "base64", "base64url" ] + default: "hex" + signature_header: + description: "The HTTP header name where the HMAC signature is sent." + type: string + minLength: 1 + secret: + type: string + minLength: 1 + required: + - hash + - encoding + - signature_header + - secret + HTTPSourceConfig: type: object default: diff --git a/plugins/basic-auth/plugin.go b/plugins/basic-auth/plugin.go new file mode 100644 index 00000000..9d31d49a --- /dev/null +++ b/plugins/basic-auth/plugin.go @@ -0,0 +1,38 @@ +package basic_auth + +import ( + "context" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/webhookx-io/webhookx/db/entities" + "github.com/webhookx-io/webhookx/pkg/http/response" + "github.com/webhookx-io/webhookx/pkg/plugin" +) + +type Config struct { + Username string `json:"username"` + Password string `json:"password"` +} + +func (c Config) Schema() *openapi3.Schema { + return entities.LookupSchema("BasicAuthPluginConfiguration") +} + +type BasicAuthPlugin struct { + plugin.BasePlugin[Config] +} + +func (p *BasicAuthPlugin) Name() string { + return "basic-auth" +} + +func (p *BasicAuthPlugin) ExecuteInbound(ctx context.Context, inbound *plugin.Inbound) (result plugin.InboundResult, err error) { + username, password, ok := inbound.Request.BasicAuth() + if !ok || username != p.Config.Username || password != p.Config.Password { + response.JSON(inbound.Response, 401, `{"message":"Unauthorized"}`) + result.Terminated = true + } + + result.Payload = inbound.RawBody + return +} diff --git a/plugins/hmac-auth/plugin.go b/plugins/hmac-auth/plugin.go new file mode 100644 index 00000000..1d119579 --- /dev/null +++ b/plugins/hmac-auth/plugin.go @@ -0,0 +1,93 @@ +package hmac_auth + +import ( + "context" + "crypto/hmac" + "crypto/md5" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "hash" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/webhookx-io/webhookx/db/entities" + "github.com/webhookx-io/webhookx/pkg/http/response" + "github.com/webhookx-io/webhookx/pkg/plugin" +) + +var ( + ErrInvalidHashMethod = errors.New("invalid hash method") + ErrInvalidEncodingMethod = errors.New("invalid encoding method") +) + +var hashes = map[string]func() hash.Hash{ + "md5": md5.New, + "sha-1": sha1.New, + "sha-256": sha256.New, + "sha-512": sha512.New, +} + +func Hmac(algorithm string, key string, data string) []byte { + fn, ok := hashes[algorithm] + if !ok { + panic(fmt.Errorf("%w: %s", ErrInvalidHashMethod, algorithm)) + } + h := hmac.New(fn, []byte(key)) + h.Write([]byte(data)) + return h.Sum(nil) +} + +func encode(encoding string, data []byte) string { + switch encoding { + case "hex": + return hex.EncodeToString(data) + case "base64": + return base64.StdEncoding.EncodeToString(data) + case "base64url": + return base64.RawURLEncoding.EncodeToString(data) + default: + panic(fmt.Errorf("%w: %s", ErrInvalidEncodingMethod, encoding)) + } +} + +type Config struct { + Hash string `json:"hash"` + Encoding string `json:"encoding"` + SignatureHeader string `json:"signature_header"` + Secret string `json:"secret"` +} + +func (c Config) Schema() *openapi3.Schema { + return entities.LookupSchema("HmacAuthPluginConfiguration") +} + +type HmacAuthPlugin struct { + plugin.BasePlugin[Config] +} + +func (p *HmacAuthPlugin) Name() string { + return "hmac-auth" +} + +func (p *HmacAuthPlugin) ExecuteInbound(ctx context.Context, inbound *plugin.Inbound) (result plugin.InboundResult, err error) { + matched := false + signature := inbound.Request.Header.Get(p.Config.SignatureHeader) + if len(signature) > 0 { + bytes := Hmac(p.Config.Hash, p.Config.Secret, string(inbound.RawBody)) + expectedSignature := encode(p.Config.Encoding, bytes) + matched = subtle.ConstantTimeCompare([]byte(signature), []byte(expectedSignature)) == 1 + } + + if !matched { + response.JSON(inbound.Response, 401, `{"message":"Unauthorized"}`) + result.Terminated = true + } + result.Payload = inbound.RawBody + + return +} diff --git a/plugins/key-auth/plugin.go b/plugins/key-auth/plugin.go new file mode 100644 index 00000000..f3ab574a --- /dev/null +++ b/plugins/key-auth/plugin.go @@ -0,0 +1,59 @@ +package key_auth + +import ( + "context" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/webhookx-io/webhookx/db/entities" + "github.com/webhookx-io/webhookx/pkg/http/response" + "github.com/webhookx-io/webhookx/pkg/plugin" +) + +type Config struct { + ParamName string `json:"param_name"` + ParamLocations []string `json:"param_locations"` + Key string `json:"key"` +} + +func (c Config) Schema() *openapi3.Schema { + return entities.LookupSchema("KeyAuthPluginConfiguration") +} + +type KeyAuthPlugin struct { + plugin.BasePlugin[Config] +} + +func (p *KeyAuthPlugin) Name() string { + return "key-auth" +} + +func (p *KeyAuthPlugin) ExecuteInbound(ctx context.Context, inbound *plugin.Inbound) (result plugin.InboundResult, err error) { + name := p.Config.ParamName + key := p.Config.Key + + querys := inbound.Request.URL.Query() + headers := inbound.Request.Header + + found := false + for _, source := range p.Config.ParamLocations { + var value string + switch source { + case "query": + value = querys.Get(name) + case "header": + value = headers.Get(name) + } + if value == key { + found = true + break + } + } + + if !found { + response.JSON(inbound.Response, 401, `{"message":"Unauthorized"}`) + result.Terminated = true + } + + result.Payload = inbound.RawBody + return +} diff --git a/plugins/plugins.go b/plugins/plugins.go index 38d5ea2a..0dd72dc9 100644 --- a/plugins/plugins.go +++ b/plugins/plugins.go @@ -2,8 +2,11 @@ package plugins import ( "github.com/webhookx-io/webhookx/pkg/plugin" + basic_auth "github.com/webhookx-io/webhookx/plugins/basic-auth" "github.com/webhookx-io/webhookx/plugins/function" + hmac_auth "github.com/webhookx-io/webhookx/plugins/hmac-auth" "github.com/webhookx-io/webhookx/plugins/jsonschema_validator" + key_auth "github.com/webhookx-io/webhookx/plugins/key-auth" "github.com/webhookx-io/webhookx/plugins/wasm" "github.com/webhookx-io/webhookx/plugins/webhookx_signature" ) @@ -21,4 +24,13 @@ func LoadPlugins() { plugin.RegisterPlugin(plugin.TypeInbound, "jsonschema-validator", func() plugin.Plugin { return &jsonschema_validator.SchemaValidatorPlugin{} }) + plugin.RegisterPlugin(plugin.TypeInbound, "basic-auth", func() plugin.Plugin { + return &basic_auth.BasicAuthPlugin{} + }) + plugin.RegisterPlugin(plugin.TypeInbound, "key-auth", func() plugin.Plugin { + return &key_auth.KeyAuthPlugin{} + }) + plugin.RegisterPlugin(plugin.TypeInbound, "hmac-auth", func() plugin.Plugin { + return &hmac_auth.HmacAuthPlugin{} + }) } diff --git a/test/admin/plugins_test.go b/test/admin/plugins_test.go index 3625eed6..6adabf17 100644 --- a/test/admin/plugins_test.go +++ b/test/admin/plugins_test.go @@ -2,6 +2,8 @@ package admin import ( "context" + "strings" + "github.com/go-resty/resty/v2" . "github.com/onsi/ginkgo/v2" "github.com/stretchr/testify/assert" @@ -10,13 +12,13 @@ import ( "github.com/webhookx-io/webhookx/db" "github.com/webhookx-io/webhookx/db/entities" "github.com/webhookx-io/webhookx/pkg/plugin" + key_auth "github.com/webhookx-io/webhookx/plugins/key-auth" "github.com/webhookx-io/webhookx/test/fixtures/plugins/hello" "github.com/webhookx-io/webhookx/test/fixtures/plugins/inbound" "github.com/webhookx-io/webhookx/test/fixtures/plugins/outbound" "github.com/webhookx-io/webhookx/test/helper" "github.com/webhookx-io/webhookx/test/helper/factory" "github.com/webhookx-io/webhookx/utils" - "strings" ) var _ = Describe("/plugins", Ordered, func() { @@ -213,6 +215,95 @@ var _ = Describe("/plugins", Ordered, func() { }) }) + Context("basic-auth plugin", func() { + It("return 201", func() { + source := factory.SourceP() + assert.Nil(GinkgoT(), db.Sources.Insert(context.TODO(), source)) + resp, err := adminClient.R(). + SetBody(map[string]interface{}{ + "name": "basic-auth", + "source_id": source.ID, + "config": map[string]string{ + "username": "foo", + "password": "bar", + }, + }). + SetResult(entities.Plugin{}). + Post("/workspaces/default/plugins") + + assert.Nil(GinkgoT(), err) + assert.Equal(GinkgoT(), 201, resp.StatusCode()) + + result := resp.Result().(*entities.Plugin) + assert.Equal(GinkgoT(), "basic-auth", result.Name) + assert.Equal(GinkgoT(), source.ID, *result.SourceId) + assert.Equal(GinkgoT(), "foo", result.Config["username"]) + assert.Equal(GinkgoT(), "bar", result.Config["password"]) + }) + }) + + Context("key-auth plugin", func() { + It("return 201", func() { + source := factory.SourceP() + assert.Nil(GinkgoT(), db.Sources.Insert(context.TODO(), source)) + resp, err := adminClient.R(). + SetBody(map[string]interface{}{ + "name": "key-auth", + "source_id": source.ID, + "config": map[string]interface{}{ + "param_name": "apikey", + "param_locations": []string{"header", "query"}, + "key": "mykey", + }, + }). + SetResult(entities.Plugin{}). + Post("/workspaces/default/plugins") + + assert.Nil(GinkgoT(), err) + assert.Equal(GinkgoT(), 201, resp.StatusCode()) + + result := resp.Result().(*entities.Plugin) + assert.Equal(GinkgoT(), "key-auth", result.Name) + assert.Equal(GinkgoT(), source.ID, *result.SourceId) + cfg := key_auth.Config{} + utils.MapToStruct(result.Config, &cfg) + assert.Equal(GinkgoT(), "apikey", cfg.ParamName) + assert.Equal(GinkgoT(), []string{"header", "query"}, cfg.ParamLocations) + assert.Equal(GinkgoT(), "mykey", cfg.Key) + }) + }) + + Context("hmac-auth plugin", func() { + It("return 201", func() { + source := factory.SourceP() + assert.Nil(GinkgoT(), db.Sources.Insert(context.TODO(), source)) + resp, err := adminClient.R(). + SetBody(map[string]interface{}{ + "name": "hmac-auth", + "source_id": source.ID, + "config": map[string]interface{}{ + "hash": "sha-256", + "encoding": "base64", + "signature_header": "x-signature", + "secret": "mykey", + }, + }). + SetResult(entities.Plugin{}). + Post("/workspaces/default/plugins") + + assert.Nil(GinkgoT(), err) + assert.Equal(GinkgoT(), 201, resp.StatusCode()) + + result := resp.Result().(*entities.Plugin) + assert.Equal(GinkgoT(), "hmac-auth", result.Name) + assert.Equal(GinkgoT(), source.ID, *result.SourceId) + assert.Equal(GinkgoT(), "sha-256", result.Config["hash"]) + assert.Equal(GinkgoT(), "base64", result.Config["encoding"]) + assert.Equal(GinkgoT(), "x-signature", result.Config["signature_header"]) + assert.Equal(GinkgoT(), "mykey", result.Config["secret"]) + }) + }) + Context("errors", func() { It("return HTTP 400", func() { resp, err := adminClient.R(). diff --git a/test/plugins/basic_auth_test.go b/test/plugins/basic_auth_test.go new file mode 100644 index 00000000..c8714f16 --- /dev/null +++ b/test/plugins/basic_auth_test.go @@ -0,0 +1,110 @@ +package plugins_test + +import ( + "github.com/go-resty/resty/v2" + . "github.com/onsi/ginkgo/v2" + "github.com/stretchr/testify/assert" + "github.com/webhookx-io/webhookx/app" + "github.com/webhookx-io/webhookx/db/entities" + basic_auth "github.com/webhookx-io/webhookx/plugins/basic-auth" + "github.com/webhookx-io/webhookx/test/helper" + "github.com/webhookx-io/webhookx/test/helper/factory" + "github.com/webhookx-io/webhookx/utils" + "time" +) + +var _ = Describe("basic-auth", Ordered, func() { + Context("", func() { + var proxyClient *resty.Client + var app *app.Application + + entitiesConfig := helper.EntitiesConfig{ + Endpoints: []*entities.Endpoint{factory.EndpointP()}, + Sources: []*entities.Source{ + factory.SourceP(func(o *entities.Source) { + o.Config.HTTP.Path = "/" + }), + factory.SourceP(func(o *entities.Source) { + o.Config.HTTP.Path = "/empty-password" + })}, + } + entitiesConfig.Plugins = []*entities.Plugin{ + factory.PluginP( + factory.WithPluginSourceID(entitiesConfig.Sources[0].ID), + factory.WithPluginName("basic-auth"), + factory.WithPluginConfig(basic_auth.Config{ + Username: "username", + Password: "password", + }), + ), + factory.PluginP( + factory.WithPluginSourceID(entitiesConfig.Sources[1].ID), + factory.WithPluginName("basic-auth"), + factory.WithPluginConfig(basic_auth.Config{ + Username: "username", + Password: "", + }), + ), + } + + BeforeAll(func() { + helper.InitDB(true, &entitiesConfig) + proxyClient = helper.ProxyClient() + + app = utils.Must(helper.Start(map[string]string{})) + err := helper.WaitForServer(helper.ProxyHttpURL, time.Second) + assert.NoError(GinkgoT(), err) + }) + + AfterAll(func() { + app.Stop() + }) + + It("should pass when passing right auth", func() { + resp, err := proxyClient.R(). + SetBody(`{"event_type": "foo.bar","data": {"key": "value"}}`). + SetBasicAuth("username", "password"). + Post("/") + assert.NoError(GinkgoT(), err) + assert.Equal(GinkgoT(), 200, resp.StatusCode()) + }) + + It("should pass when passing empty password", func() { + resp, err := proxyClient.R(). + SetBody(`{"event_type": "foo.bar","data": {"key": "value"}}`). + SetBasicAuth("username", ""). + Post("/empty-password") + assert.NoError(GinkgoT(), err) + assert.Equal(GinkgoT(), 200, resp.StatusCode()) + }) + + It("should deny when missing auth", func() { + resp, err := proxyClient.R(). + SetBody(`{"event_type": "foo.bar","data": {"key": "value"}}`). + Post("/") + assert.NoError(GinkgoT(), err) + assert.Equal(GinkgoT(), 401, resp.StatusCode()) + assert.Equal(GinkgoT(), `{"message":"Unauthorized"}`, string(resp.Body())) + }) + + It("should deny when passing wrong auth", func() { + resp, err := proxyClient.R(). + SetBody(`{"event_type": "foo.bar","data": {"key": "value"}}`). + SetBasicAuth("username", ""). + Post("/") + assert.NoError(GinkgoT(), err) + assert.Equal(GinkgoT(), 401, resp.StatusCode()) + assert.Equal(GinkgoT(), `{"message":"Unauthorized"}`, string(resp.Body())) + }) + + It("should deny when passing inavlid auth", func() { + resp, err := proxyClient.R(). + SetBody(`{"event_type": "foo.bar","data": {"key": "value"}}`). + SetHeader("Authorization", "Basic "). + Post("/") + assert.NoError(GinkgoT(), err) + assert.Equal(GinkgoT(), 401, resp.StatusCode()) + assert.Equal(GinkgoT(), `{"message":"Unauthorized"}`, string(resp.Body())) + }) + }) +}) diff --git a/test/plugins/hmac_auth_test.go b/test/plugins/hmac_auth_test.go new file mode 100644 index 00000000..7ab524b3 --- /dev/null +++ b/test/plugins/hmac_auth_test.go @@ -0,0 +1,204 @@ +package plugins_test + +import ( + "crypto/hmac" + "crypto/md5" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "encoding/base64" + "encoding/hex" + "time" + + "github.com/go-resty/resty/v2" + . "github.com/onsi/ginkgo/v2" + "github.com/stretchr/testify/assert" + "github.com/webhookx-io/webhookx/app" + "github.com/webhookx-io/webhookx/db/entities" + hmac_auth "github.com/webhookx-io/webhookx/plugins/hmac-auth" + "github.com/webhookx-io/webhookx/test/helper" + "github.com/webhookx-io/webhookx/test/helper/factory" + "github.com/webhookx-io/webhookx/utils" +) + +var _ = Describe("hmac-auth", Ordered, func() { + Context("", func() { + var proxyClient *resty.Client + var app *app.Application + var payload = `{"event_type": "foo.bar","data": {"key": "value"}}` + + var signature []byte + + entitiesConfig := helper.EntitiesConfig{ + Endpoints: []*entities.Endpoint{factory.EndpointP()}, + Sources: []*entities.Source{ + factory.SourceP(func(o *entities.Source) { o.Config.HTTP.Path = "/sha256-hex" }), + factory.SourceP(func(o *entities.Source) { o.Config.HTTP.Path = "/sha256-base64" }), + factory.SourceP(func(o *entities.Source) { o.Config.HTTP.Path = "/sha256-base64url" }), + factory.SourceP(func(o *entities.Source) { o.Config.HTTP.Path = "/md5-hex" }), + factory.SourceP(func(o *entities.Source) { o.Config.HTTP.Path = "/sha1-hex" }), + factory.SourceP(func(o *entities.Source) { o.Config.HTTP.Path = "/sha512-hex" }), + }, + } + entitiesConfig.Plugins = []*entities.Plugin{ + factory.PluginP( + factory.WithPluginSourceID(entitiesConfig.Sources[0].ID), + factory.WithPluginName("hmac-auth"), + factory.WithPluginConfig(hmac_auth.Config{ + Hash: "sha-256", + Encoding: "hex", + SignatureHeader: "X-Signature", + Secret: "mykey", + }), + ), + factory.PluginP( + factory.WithPluginSourceID(entitiesConfig.Sources[1].ID), + factory.WithPluginName("hmac-auth"), + factory.WithPluginConfig(hmac_auth.Config{ + Hash: "sha-256", + Encoding: "base64", + SignatureHeader: "X-Signature", + Secret: "mykey", + }), + ), + factory.PluginP( + factory.WithPluginSourceID(entitiesConfig.Sources[2].ID), + factory.WithPluginName("hmac-auth"), + factory.WithPluginConfig(hmac_auth.Config{ + Hash: "sha-256", + Encoding: "base64url", + SignatureHeader: "X-Signature", + Secret: "mykey", + }), + ), + factory.PluginP( + factory.WithPluginSourceID(entitiesConfig.Sources[3].ID), + factory.WithPluginName("hmac-auth"), + factory.WithPluginConfig(hmac_auth.Config{ + Hash: "md5", + Encoding: "hex", + SignatureHeader: "X-Signature", + Secret: "mykey", + }), + ), + factory.PluginP( + factory.WithPluginSourceID(entitiesConfig.Sources[4].ID), + factory.WithPluginName("hmac-auth"), + factory.WithPluginConfig(hmac_auth.Config{ + Hash: "sha-1", + Encoding: "hex", + SignatureHeader: "X-Signature", + Secret: "mykey", + }), + ), + factory.PluginP( + factory.WithPluginSourceID(entitiesConfig.Sources[5].ID), + factory.WithPluginName("hmac-auth"), + factory.WithPluginConfig(hmac_auth.Config{ + Hash: "sha-512", + Encoding: "hex", + SignatureHeader: "X-Signature", + Secret: "mykey", + }), + ), + } + + BeforeAll(func() { + helper.InitDB(true, &entitiesConfig) + proxyClient = helper.ProxyClient() + + app = utils.Must(helper.Start(map[string]string{})) + err := helper.WaitForServer(helper.ProxyHttpURL, time.Second) + assert.NoError(GinkgoT(), err) + + h := hmac.New(sha256.New, []byte("mykey")) + h.Write([]byte(payload)) + signature = h.Sum(nil) + }) + + AfterAll(func() { + app.Stop() + }) + + Context("sha256", func() { + It("should pass when passing right signature", func() { + paths := []string{"/sha256-hex", "/sha256-base64", "/sha256-base64url"} + signatures := []string{ + hex.EncodeToString(signature), + base64.StdEncoding.EncodeToString(signature), + base64.RawURLEncoding.EncodeToString(signature), + } + + for i, path := range paths { + resp, err := proxyClient.R(). + SetBody(payload). + SetHeader("X-Signature", signatures[i]). + Post(path) + assert.NoError(GinkgoT(), err) + assert.Equal(GinkgoT(), 200, resp.StatusCode()) + } + }) + + It("should deny when missing signature", func() { + resp, err := proxyClient.R(). + SetBody(payload). + Post("/sha256-hex") + assert.NoError(GinkgoT(), err) + assert.Equal(GinkgoT(), 401, resp.StatusCode()) + assert.Equal(GinkgoT(), `{"message":"Unauthorized"}`, string(resp.Body())) + }) + + It("should deny when passing wrong signature", func() { + resp, err := proxyClient.R(). + SetBody(`{"event_type": "foo.bar","data": {"key": "value"}}`). + SetHeader("X-Signature", "wrong"). + Post("/sha256-hex") + assert.NoError(GinkgoT(), err) + assert.Equal(GinkgoT(), 401, resp.StatusCode()) + assert.Equal(GinkgoT(), `{"message":"Unauthorized"}`, string(resp.Body())) + }) + }) + + Context("md5", func() { + It("should pass when passing right signature", func() { + h := hmac.New(md5.New, []byte("mykey")) + h.Write([]byte(payload)) + signature := h.Sum(nil) + resp, err := proxyClient.R(). + SetBody(payload). + SetHeader("X-Signature", hex.EncodeToString(signature)). + Post("/md5-hex") + assert.NoError(GinkgoT(), err) + assert.Equal(GinkgoT(), 200, resp.StatusCode()) + }) + }) + + Context("sha1", func() { + It("should pass when passing right signature", func() { + h := hmac.New(sha1.New, []byte("mykey")) + h.Write([]byte(payload)) + signature := h.Sum(nil) + resp, err := proxyClient.R(). + SetBody(payload). + SetHeader("X-Signature", hex.EncodeToString(signature)). + Post("/sha1-hex") + assert.NoError(GinkgoT(), err) + assert.Equal(GinkgoT(), 200, resp.StatusCode()) + }) + }) + + Context("sha512", func() { + It("should pass when passing right signature", func() { + h := hmac.New(sha512.New, []byte("mykey")) + h.Write([]byte(payload)) + signature := h.Sum(nil) + resp, err := proxyClient.R(). + SetBody(payload). + SetHeader("X-Signature", hex.EncodeToString(signature)). + Post("/sha512-hex") + assert.NoError(GinkgoT(), err) + assert.Equal(GinkgoT(), 200, resp.StatusCode()) + }) + }) + }) +}) diff --git a/test/plugins/key_auth_test.go b/test/plugins/key_auth_test.go new file mode 100644 index 00000000..33655f47 --- /dev/null +++ b/test/plugins/key_auth_test.go @@ -0,0 +1,97 @@ +package plugins_test + +import ( + "time" + + "github.com/go-resty/resty/v2" + . "github.com/onsi/ginkgo/v2" + "github.com/stretchr/testify/assert" + "github.com/webhookx-io/webhookx/app" + "github.com/webhookx-io/webhookx/db/entities" + key_auth "github.com/webhookx-io/webhookx/plugins/key-auth" + "github.com/webhookx-io/webhookx/test/helper" + "github.com/webhookx-io/webhookx/test/helper/factory" + "github.com/webhookx-io/webhookx/utils" +) + +var _ = Describe("key-auth", Ordered, func() { + Context("", func() { + var proxyClient *resty.Client + var app *app.Application + + entitiesConfig := helper.EntitiesConfig{ + Endpoints: []*entities.Endpoint{factory.EndpointP()}, + Sources: []*entities.Source{factory.SourceP()}, + } + entitiesConfig.Plugins = []*entities.Plugin{ + factory.PluginP( + factory.WithPluginSourceID(entitiesConfig.Sources[0].ID), + factory.WithPluginName("key-auth"), + factory.WithPluginConfig(key_auth.Config{ + ParamName: "apikey", + ParamLocations: []string{"query", "header"}, + Key: "thisisasecret", + }), + ), + } + + BeforeAll(func() { + helper.InitDB(true, &entitiesConfig) + proxyClient = helper.ProxyClient() + + app = utils.Must(helper.Start(map[string]string{})) + err := helper.WaitForServer(helper.ProxyHttpURL, time.Second) + assert.NoError(GinkgoT(), err) + }) + + AfterAll(func() { + app.Stop() + }) + + It("should pass when passing right auth in header", func() { + resp, err := proxyClient.R(). + SetBody(`{"event_type": "foo.bar","data": {"key": "value"}}`). + SetHeader("apikey", "thisisasecret"). + Post("/") + assert.NoError(GinkgoT(), err) + assert.Equal(GinkgoT(), 200, resp.StatusCode()) + }) + + It("should pass when passing right auth in query", func() { + resp, err := proxyClient.R(). + SetBody(`{"event_type": "foo.bar","data": {"key": "value"}}`). + SetHeader("apikey", "thisisasecret"). + Post("/?apikey=thisisasecret") + assert.NoError(GinkgoT(), err) + assert.Equal(GinkgoT(), 200, resp.StatusCode()) + }) + + It("should deny when missing auth", func() { + resp, err := proxyClient.R(). + SetBody(`{"event_type": "foo.bar","data": {"key": "value"}}`). + Post("/") + assert.NoError(GinkgoT(), err) + assert.Equal(GinkgoT(), 401, resp.StatusCode()) + assert.Equal(GinkgoT(), `{"message":"Unauthorized"}`, string(resp.Body())) + }) + + It("should deny when passing wrong auth in header", func() { + resp, err := proxyClient.R(). + SetBody(`{"event_type": "foo.bar","data": {"key": "value"}}`). + SetHeader("apikey", "wrongkey"). + Post("/") + assert.NoError(GinkgoT(), err) + assert.Equal(GinkgoT(), 401, resp.StatusCode()) + assert.Equal(GinkgoT(), `{"message":"Unauthorized"}`, string(resp.Body())) + }) + + It("should deny when passing wrong auth in query", func() { + resp, err := proxyClient.R(). + SetBody(`{"event_type": "foo.bar","data": {"key": "value"}}`). + Post("/?apikey=wrongkey") + assert.NoError(GinkgoT(), err) + assert.Equal(GinkgoT(), 401, resp.StatusCode()) + assert.Equal(GinkgoT(), `{"message":"Unauthorized"}`, string(resp.Body())) + }) + }) +})