From e2c8f1d223eea99da2f750674095645a4bed9647 Mon Sep 17 00:00:00 2001 From: Jonas Plum Date: Sat, 20 Jul 2024 06:39:02 +0200 Subject: [PATCH] feat: add reactions (#1074) --- .github/workflows/ci.yml | 23 ++ .github/workflows/semantic-pull-request.yml | 2 +- .gitignore | 2 + .golangci.yml | 3 + Makefile | 13 +- app/app.go | 44 +++ app/bootstrap.go | 25 ++ app/fakedata.go | 27 ++ app/flags.go | 80 +++++ app/flags_test.go | 41 +++ app/migrate.go | 72 ++++ app/migrate_test.go | 21 ++ routes.go => app/routes.go | 22 +- bootstrapcmd.go | 50 --- cmd.go | 78 ----- fakedata/records.go | 75 ++++- fakedata/records_test.go | 29 ++ fakedata/text_test.go | 48 +++ flags.go | 22 -- go.mod | 5 +- go.sum | 1 + main.go | 26 +- migrations/1_collections.go | 10 +- migrations/5_reactions.go | 40 +++ migrations/migrations.go | 1 + reaction/action/action.go | 44 +++ reaction/action/python/python.go | 105 ++++++ reaction/action/webhook/payload.go | 20 ++ reaction/action/webhook/payload_test.go | 49 +++ reaction/action/webhook/response.go | 12 + reaction/action/webhook/webhook.go | 39 +++ reaction/trigger.go | 13 + reaction/trigger/hook/hook.go | 103 ++++++ reaction/trigger/webhook/request.go | 23 ++ reaction/trigger/webhook/request_test.go | 37 ++ reaction/trigger/webhook/webhook.go | 146 ++++++++ testing/collection_reaction_test.go | 250 ++++++++++++++ testing/reaction_test.go | 177 ++++++++++ testing/routes_test.go | 79 +++++ testing/testdata.go | 103 ++++++ testing/testing.go | 172 ++++++++++ ui/src/components/common/DeleteDialog.vue | 4 +- ui/src/components/form/GrowTextarea.vue | 49 +++ ui/src/components/form/ListInput.vue | 70 ++++ ui/src/components/form/MultiSelect.vue | 98 ++++++ ui/src/components/form/TextInput.vue | 75 +++++ ui/src/components/layout/SideBar.vue | 15 +- .../reaction/ActionPythonFormFields.vue | 36 ++ .../reaction/ActionWebhookFormFields.vue | 40 +++ .../components/reaction/ReactionDisplay.vue | 62 ++++ ui/src/components/reaction/ReactionForm.vue | 318 ++++++++++++++++++ ui/src/components/reaction/ReactionList.vue | 81 +++++ .../components/reaction/ReactionNewDialog.vue | 63 ++++ .../TriggerHookFormFieldCollections.vue | 37 ++ .../reaction/TriggerHookFormFieldEvents.vue | 36 ++ .../reaction/TriggerHookFormFields.vue | 36 ++ .../reaction/TriggerWebhookFormFields.vue | 47 +++ .../components/ticket/TicketCloseDialog.vue | 8 +- ui/src/components/ticket/TicketList.vue | 2 +- ui/src/components/ticket/TicketNewDialog.vue | 4 +- ui/src/components/ticket/TicketPanel.vue | 2 +- .../ticket/comment/TicketComment.vue | 6 +- .../components/ticket/file/FileAddDialog.vue | 8 +- .../components/ticket/link/LinkAddDialog.vue | 8 +- ui/src/components/ticket/task/TicketTasks.vue | 1 - .../ticket/timeline/TicketTimelineItem.vue | 8 +- ui/src/components/ui/tags-input/TagsInput.vue | 36 ++ .../ui/tags-input/TagsInputInput.vue | 23 ++ .../ui/tags-input/TagsInputItem.vue | 30 ++ .../ui/tags-input/TagsInputItemDelete.vue | 29 ++ .../ui/tags-input/TagsInputItemText.vue | 23 ++ ui/src/components/ui/tags-input/index.ts | 5 + ui/src/lib/types.ts | 13 + ui/src/router/index.ts | 6 + ui/src/views/ReactionView.vue | 35 ++ ui/ui.go | 15 + ui/ui_test.go | 42 +++ webhooks.go => webhook/webhooks.go | 24 +- 78 files changed, 3270 insertions(+), 257 deletions(-) create mode 100644 app/app.go create mode 100644 app/bootstrap.go create mode 100644 app/fakedata.go create mode 100644 app/flags.go create mode 100644 app/flags_test.go create mode 100644 app/migrate.go create mode 100644 app/migrate_test.go rename routes.go => app/routes.go (76%) delete mode 100644 bootstrapcmd.go delete mode 100644 cmd.go create mode 100644 fakedata/records_test.go create mode 100644 fakedata/text_test.go delete mode 100644 flags.go create mode 100644 migrations/5_reactions.go create mode 100644 reaction/action/action.go create mode 100644 reaction/action/python/python.go create mode 100644 reaction/action/webhook/payload.go create mode 100644 reaction/action/webhook/payload_test.go create mode 100644 reaction/action/webhook/response.go create mode 100644 reaction/action/webhook/webhook.go create mode 100644 reaction/trigger.go create mode 100644 reaction/trigger/hook/hook.go create mode 100644 reaction/trigger/webhook/request.go create mode 100644 reaction/trigger/webhook/request_test.go create mode 100644 reaction/trigger/webhook/webhook.go create mode 100644 testing/collection_reaction_test.go create mode 100644 testing/reaction_test.go create mode 100644 testing/routes_test.go create mode 100644 testing/testdata.go create mode 100644 testing/testing.go create mode 100644 ui/src/components/form/GrowTextarea.vue create mode 100644 ui/src/components/form/ListInput.vue create mode 100644 ui/src/components/form/MultiSelect.vue create mode 100644 ui/src/components/form/TextInput.vue create mode 100644 ui/src/components/reaction/ActionPythonFormFields.vue create mode 100644 ui/src/components/reaction/ActionWebhookFormFields.vue create mode 100644 ui/src/components/reaction/ReactionDisplay.vue create mode 100644 ui/src/components/reaction/ReactionForm.vue create mode 100644 ui/src/components/reaction/ReactionList.vue create mode 100644 ui/src/components/reaction/ReactionNewDialog.vue create mode 100644 ui/src/components/reaction/TriggerHookFormFieldCollections.vue create mode 100644 ui/src/components/reaction/TriggerHookFormFieldEvents.vue create mode 100644 ui/src/components/reaction/TriggerHookFormFields.vue create mode 100644 ui/src/components/reaction/TriggerWebhookFormFields.vue create mode 100644 ui/src/components/ui/tags-input/TagsInput.vue create mode 100644 ui/src/components/ui/tags-input/TagsInputInput.vue create mode 100644 ui/src/components/ui/tags-input/TagsInputItem.vue create mode 100644 ui/src/components/ui/tags-input/TagsInputItemDelete.vue create mode 100644 ui/src/components/ui/tags-input/TagsInputItemText.vue create mode 100644 ui/src/components/ui/tags-input/index.ts create mode 100644 ui/src/views/ReactionView.vue create mode 100644 ui/ui.go create mode 100644 ui/ui_test.go rename webhooks.go => webhook/webhooks.go (80%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a718585..698e770 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,22 @@ on: release: { types: [ published ] } jobs: + fmt: + name: Fmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: { go-version: '1.22' } + - uses: oven-sh/setup-bun@v1 + + - run: make build-ui + + - run: make install + - run: make fmt + + - run: git diff --exit-code + lint: name: Lint runs-on: ubuntu-latest @@ -31,3 +47,10 @@ jobs: - run: make build-ui - run: make test + + - run: make test-coverage + + - uses: codecov/codecov-action@v4 + with: + files: ./coverage.out + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/semantic-pull-request.yml b/.github/workflows/semantic-pull-request.yml index 51816c8..ed9e972 100644 --- a/.github/workflows/semantic-pull-request.yml +++ b/.github/workflows/semantic-pull-request.yml @@ -20,6 +20,6 @@ jobs: with: scopes: | deps - subjectPattern: "^(?!deps$).+" + subjectPattern: ^(?![A-Z]).+$ env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 35ed655..deb0cd8 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,5 @@ dist pb_data catalyst catalyst_data + +coverage.out diff --git a/.golangci.yml b/.golangci.yml index 4349820..92d4e29 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -12,6 +12,7 @@ linters: - nestif # disable + - bodyclose - depguard - dupl - err113 @@ -28,6 +29,8 @@ linters: - lll - makezero - mnd + - paralleltest + - perfsprint - prealloc - tagalign - tagliatelle diff --git a/Makefile b/Makefile index 6af92e8..a2d2038 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,9 @@ .PHONY: install install: @echo "Installing..." - go install github.com/bombsimon/wsl/v4/cmd...@master - go install mvdan.cc/gofumpt@latest - go install github.com/daixiang0/gci@latest + go install github.com/bombsimon/wsl/v4/cmd...@v4.4.1 + go install mvdan.cc/gofumpt@v0.6.0 + go install github.com/daixiang0/gci@v0.13.4 .PHONY: fmt fmt: @@ -26,6 +26,13 @@ test: go test -v ./... cd ui && bun test +.PHONY: test-coverage +test-coverage: + @echo "Testing with coverage..." + go test -coverpkg=./... -coverprofile=coverage.out ./... + go tool cover -func=coverage.out + go tool cover -html=coverage.out + .PHONY: build-ui build-ui: @echo "Building..." diff --git a/app/app.go b/app/app.go new file mode 100644 index 0000000..68e80b4 --- /dev/null +++ b/app/app.go @@ -0,0 +1,44 @@ +package app + +import ( + "os" + "strings" + + "github.com/pocketbase/pocketbase" + "github.com/pocketbase/pocketbase/core" + + "github.com/SecurityBrewery/catalyst/migrations" + "github.com/SecurityBrewery/catalyst/reaction" + "github.com/SecurityBrewery/catalyst/webhook" +) + +func init() { + migrations.Register() +} + +func App(dir string) *pocketbase.PocketBase { + app := pocketbase.NewWithConfig(pocketbase.Config{ + DefaultDev: dev(), + DefaultDataDir: dir, + }) + + BindHooks(app) + + // Register additional commands + app.RootCmd.AddCommand(bootstrapCmd(app)) + app.RootCmd.AddCommand(fakeDataCmd(app)) + app.RootCmd.AddCommand(setFeatureFlagsCmd(app)) + + return app +} + +func BindHooks(app core.App) { + webhook.BindHooks(app) + reaction.BindHooks(app) + + app.OnBeforeServe().Add(addRoutes()) +} + +func dev() bool { + return strings.HasPrefix(os.Args[0], os.TempDir()) +} diff --git a/app/bootstrap.go b/app/bootstrap.go new file mode 100644 index 0000000..684efc5 --- /dev/null +++ b/app/bootstrap.go @@ -0,0 +1,25 @@ +package app + +import ( + "github.com/pocketbase/pocketbase/core" + "github.com/spf13/cobra" +) + +func Bootstrap(app core.App) error { + if err := app.Bootstrap(); err != nil { + return err + } + + return MigrateDBs(app) +} + +func bootstrapCmd(app core.App) *cobra.Command { + return &cobra.Command{ + Use: "bootstrap", + Run: func(_ *cobra.Command, _ []string) { + if err := Bootstrap(app); err != nil { + app.Logger().Error(err.Error()) + } + }, + } +} diff --git a/app/fakedata.go b/app/fakedata.go new file mode 100644 index 0000000..53cd9b7 --- /dev/null +++ b/app/fakedata.go @@ -0,0 +1,27 @@ +package app + +import ( + "github.com/pocketbase/pocketbase/core" + "github.com/spf13/cobra" + + "github.com/SecurityBrewery/catalyst/fakedata" +) + +func fakeDataCmd(app core.App) *cobra.Command { + var userCount, ticketCount int + + cmd := &cobra.Command{ + Use: "fake-data", + Run: func(_ *cobra.Command, _ []string) { + if err := fakedata.Generate(app, userCount, ticketCount); err != nil { + app.Logger().Error(err.Error()) + } + }, + } + + cmd.PersistentFlags().IntVar(&userCount, "users", 10, "Number of users to generate") + + cmd.PersistentFlags().IntVar(&ticketCount, "tickets", 100, "Number of tickets to generate") + + return cmd +} diff --git a/app/flags.go b/app/flags.go new file mode 100644 index 0000000..4267c31 --- /dev/null +++ b/app/flags.go @@ -0,0 +1,80 @@ +package app + +import ( + "slices" + + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/models" + "github.com/spf13/cobra" + + "github.com/SecurityBrewery/catalyst/migrations" +) + +func Flags(app core.App) ([]string, error) { + records, err := app.Dao().FindRecordsByExpr(migrations.FeatureCollectionName) + if err != nil { + return nil, err + } + + var flags []string + + for _, r := range records { + flags = append(flags, r.GetString("name")) + } + + return flags, nil +} + +func SetFlags(app core.App, args []string) error { + featureCollection, err := app.Dao().FindCollectionByNameOrId(migrations.FeatureCollectionName) + if err != nil { + return err + } + + featureRecords, err := app.Dao().FindRecordsByExpr(migrations.FeatureCollectionName) + if err != nil { + return err + } + + var existingFlags []string + + for _, featureRecord := range featureRecords { + // remove feature flags that are not in the args + if !slices.Contains(args, featureRecord.GetString("name")) { + if err := app.Dao().DeleteRecord(featureRecord); err != nil { + return err + } + + continue + } + + existingFlags = append(existingFlags, featureRecord.GetString("name")) + } + + for _, arg := range args { + if slices.Contains(existingFlags, arg) { + continue + } + + // add feature flags that are not in the args + record := models.NewRecord(featureCollection) + record.Set("name", arg) + + if err := app.Dao().SaveRecord(record); err != nil { + return err + } + } + + return nil +} + +func setFeatureFlagsCmd(app core.App) *cobra.Command { + return &cobra.Command{ + Use: "set-feature-flags", + Run: func(_ *cobra.Command, args []string) { + if err := SetFlags(app, args); err != nil { + app.Logger().Error(err.Error()) + } + }, + } +} diff --git a/app/flags_test.go b/app/flags_test.go new file mode 100644 index 0000000..0256ff5 --- /dev/null +++ b/app/flags_test.go @@ -0,0 +1,41 @@ +package app_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/SecurityBrewery/catalyst/app" + catalystTesting "github.com/SecurityBrewery/catalyst/testing" +) + +func Test_flags(t *testing.T) { + catalystApp, cleanup := catalystTesting.App(t) + defer cleanup() + + got, err := app.Flags(catalystApp) + require.NoError(t, err) + + want := []string{} + assert.ElementsMatch(t, want, got) +} + +func Test_setFlags(t *testing.T) { + catalystApp, cleanup := catalystTesting.App(t) + defer cleanup() + + require.NoError(t, app.SetFlags(catalystApp, []string{"test"})) + + got, err := app.Flags(catalystApp) + require.NoError(t, err) + + assert.ElementsMatch(t, []string{"test"}, got) + + require.NoError(t, app.SetFlags(catalystApp, []string{"test2"})) + + got, err = app.Flags(catalystApp) + require.NoError(t, err) + + assert.ElementsMatch(t, []string{"test2"}, got) +} diff --git a/app/migrate.go b/app/migrate.go new file mode 100644 index 0000000..ddc03f7 --- /dev/null +++ b/app/migrate.go @@ -0,0 +1,72 @@ +package app + +import ( + "strings" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/migrations" + "github.com/pocketbase/pocketbase/migrations/logs" + "github.com/pocketbase/pocketbase/tools/migrate" +) + +type migration struct { + db *dbx.DB + migrations migrate.MigrationsList +} + +func MigrateDBs(app core.App) error { + for _, m := range []migration{ + {db: app.DB(), migrations: migrations.AppMigrations}, + {db: app.LogsDB(), migrations: logs.LogsMigrations}, + } { + runner, err := migrate.NewRunner(m.db, m.migrations) + if err != nil { + return err + } + + if _, err := runner.Up(); err != nil { + return err + } + } + + return nil +} + +// this fix ignores some errors that come from upstream migrations. +var ignoreErrors = []string{ + "1673167670_multi_match_migrate", + "1660821103_add_user_ip_column", +} + +func isIgnored(err error) bool { + for _, ignore := range ignoreErrors { + if strings.Contains(err.Error(), ignore) { + return true + } + } + + return false +} + +func MigrateDBsDown(app core.App) error { + for _, m := range []migration{ + {db: app.DB(), migrations: migrations.AppMigrations}, + {db: app.LogsDB(), migrations: logs.LogsMigrations}, + } { + runner, err := migrate.NewRunner(m.db, m.migrations) + if err != nil { + return err + } + + if _, err := runner.Down(len(m.migrations.Items())); err != nil { + if isIgnored(err) { + continue + } + + return err + } + } + + return nil +} diff --git a/app/migrate_test.go b/app/migrate_test.go new file mode 100644 index 0000000..442cb58 --- /dev/null +++ b/app/migrate_test.go @@ -0,0 +1,21 @@ +package app_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/SecurityBrewery/catalyst/app" + "github.com/SecurityBrewery/catalyst/migrations" + catalystTesting "github.com/SecurityBrewery/catalyst/testing" +) + +func Test_MigrateDBsDown(t *testing.T) { + catalystApp, cleanup := catalystTesting.App(t) + defer cleanup() + + _, err := catalystApp.Dao().FindCollectionByNameOrId(migrations.ReactionCollectionName) + require.NoError(t, err) + + require.NoError(t, app.MigrateDBsDown(catalystApp)) +} diff --git a/routes.go b/app/routes.go similarity index 76% rename from routes.go rename to app/routes.go index dc9fa99..d3bc493 100644 --- a/routes.go +++ b/app/routes.go @@ -1,34 +1,26 @@ -package main +package app import ( - "embed" - "io/fs" "net/http" "net/http/httputil" "net/url" - "os" - "strings" "github.com/labstack/echo/v5" "github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/core" + + "github.com/SecurityBrewery/catalyst/ui" ) -//go:embed ui/dist/* -var ui embed.FS - -func dev() bool { - return strings.HasPrefix(os.Args[0], os.TempDir()) -} - func addRoutes() func(*core.ServeEvent) error { return func(e *core.ServeEvent) error { e.Router.GET("/", func(c echo.Context) error { return c.Redirect(http.StatusFound, "/ui/") }) e.Router.GET("/ui/*", staticFiles()) + e.Router.GET("/api/config", func(c echo.Context) error { - flags, err := flags(e.App) + flags, err := Flags(e.App) if err != nil { return err } @@ -55,8 +47,6 @@ func staticFiles() func(echo.Context) error { return nil } - fsys, _ := fs.Sub(ui, "ui/dist") - - return apis.StaticDirectoryHandler(fsys, true)(c) + return apis.StaticDirectoryHandler(ui.UI(), true)(c) } } diff --git a/bootstrapcmd.go b/bootstrapcmd.go deleted file mode 100644 index dd1b237..0000000 --- a/bootstrapcmd.go +++ /dev/null @@ -1,50 +0,0 @@ -package main - -import ( - "log" - - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase" - "github.com/pocketbase/pocketbase/migrations" - "github.com/pocketbase/pocketbase/migrations/logs" - "github.com/pocketbase/pocketbase/tools/migrate" - "github.com/spf13/cobra" -) - -func bootstrapCmd(app *pocketbase.PocketBase) *cobra.Command { - return &cobra.Command{ - Use: "bootstrap", - Run: func(_ *cobra.Command, _ []string) { - if err := app.Bootstrap(); err != nil { - log.Fatal(err) - } - - if err := migrateDBs(app); err != nil { - log.Fatal(err) - } - }, - } -} - -type migration struct { - db *dbx.DB - migrations migrate.MigrationsList -} - -func migrateDBs(app *pocketbase.PocketBase) error { - for _, m := range []migration{ - {db: app.DB(), migrations: migrations.AppMigrations}, - {db: app.LogsDB(), migrations: logs.LogsMigrations}, - } { - runner, err := migrate.NewRunner(m.db, m.migrations) - if err != nil { - return err - } - - if _, err := runner.Up(); err != nil { - return err - } - } - - return nil -} diff --git a/cmd.go b/cmd.go deleted file mode 100644 index 1dea175..0000000 --- a/cmd.go +++ /dev/null @@ -1,78 +0,0 @@ -package main - -import ( - "log" - "slices" - - "github.com/pocketbase/pocketbase" - "github.com/pocketbase/pocketbase/models" - "github.com/spf13/cobra" - - "github.com/SecurityBrewery/catalyst/fakedata" - "github.com/SecurityBrewery/catalyst/migrations" -) - -func fakeDataCmd(app *pocketbase.PocketBase) *cobra.Command { - var userCount, ticketCount int - - cmd := &cobra.Command{ - Use: "fake-data", - Run: func(_ *cobra.Command, _ []string) { - if err := fakedata.Generate(app, userCount, ticketCount); err != nil { - log.Fatal(err) - } - }, - } - - cmd.PersistentFlags().IntVar(&userCount, "users", 10, "Number of users to generate") - - cmd.PersistentFlags().IntVar(&ticketCount, "tickets", 100, "Number of tickets to generate") - - return cmd -} - -func setFeatureFlagsCmd(app *pocketbase.PocketBase) *cobra.Command { - return &cobra.Command{ - Use: "set-feature-flags", - Run: func(_ *cobra.Command, args []string) { - featureCollection, err := app.Dao().FindCollectionByNameOrId(migrations.FeatureCollectionName) - if err != nil { - log.Fatal(err) - } - - featureRecords, err := app.Dao().FindRecordsByExpr(migrations.FeatureCollectionName) - if err != nil { - log.Fatal(err) - } - - var existingFlags []string - - for _, featureRecord := range featureRecords { - // remove feature flags that are not in the args - if !slices.Contains(args, featureRecord.GetString("name")) { - if err := app.Dao().DeleteRecord(featureRecord); err != nil { - log.Fatal(err) - } - - continue - } - - existingFlags = append(existingFlags, featureRecord.GetString("name")) - } - - for _, arg := range args { - if slices.Contains(existingFlags, arg) { - continue - } - - // add feature flags that are not in the args - record := models.NewRecord(featureCollection) - record.Set("name", arg) - - if err := app.Dao().SaveRecord(record); err != nil { - log.Fatal(err) - } - } - }, - } -} diff --git a/fakedata/records.go b/fakedata/records.go index 7fdd0d0..f1b9be8 100644 --- a/fakedata/records.go +++ b/fakedata/records.go @@ -6,7 +6,7 @@ import ( "time" "github.com/brianvoe/gofakeit/v7" - "github.com/pocketbase/pocketbase" + "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/tools/security" @@ -19,7 +19,7 @@ const ( minimumTicketCount = 1 ) -func Generate(app *pocketbase.PocketBase, userCount, ticketCount int) error { +func Generate(app core.App, userCount, ticketCount int) error { if userCount < minimumUserCount { userCount = minimumUserCount } @@ -28,24 +28,39 @@ func Generate(app *pocketbase.PocketBase, userCount, ticketCount int) error { ticketCount = minimumTicketCount } - types, err := app.Dao().FindRecordsByExpr(migrations.TypeCollectionName) + records, err := Records(app, userCount, ticketCount) if err != nil { return err } + for _, record := range records { + if err := app.Dao().SaveRecord(record); err != nil { + return err + } + } + + return nil +} + +func Records(app core.App, userCount int, ticketCount int) ([]*models.Record, error) { + types, err := app.Dao().FindRecordsByExpr(migrations.TypeCollectionName) + if err != nil { + return nil, err + } + users := userRecords(app.Dao(), userCount) tickets := ticketRecords(app.Dao(), users, types, ticketCount) webhooks := webhookRecords(app.Dao()) + reactions := reactionRecords(app.Dao()) - for _, records := range [][]*models.Record{users, tickets, webhooks} { - for _, record := range records { - if err := app.Dao().SaveRecord(record); err != nil { - app.Logger().Error(err.Error()) - } - } - } + var records []*models.Record + records = append(records, users...) + records = append(records, types...) + records = append(records, tickets...) + records = append(records, webhooks...) + records = append(records, reactions...) - return nil + return records, nil } func userRecords(dao *daos.Dao, count int) []*models.Record { @@ -222,3 +237,41 @@ func webhookRecords(dao *daos.Dao) []*models.Record { return []*models.Record{record} } + +const ( + triggerWebhook = `{"token":"1234567890","path":"webhook"}` + reactionPython = `{"requirements":"requests","script":"import sys\n\nprint(sys.argv[1])"}` + triggerHook = `{"collections":["tickets","comments"],"events":["create","update","delete"]}` + reactionWebhook = `{"headers":["Content-Type: application/json"],"url":"http://localhost:8080/webhook"}` +) + +func reactionRecords(dao *daos.Dao) []*models.Record { + var records []*models.Record + + collection, err := dao.FindCollectionByNameOrId(migrations.ReactionCollectionName) + if err != nil { + panic(err) + } + + record := models.NewRecord(collection) + record.SetId("w_" + security.PseudorandomString(10)) + record.Set("name", "Test Reaction") + record.Set("trigger", "webhook") + record.Set("triggerdata", triggerWebhook) + record.Set("action", "python") + record.Set("actiondata", reactionPython) + + records = append(records, record) + + record = models.NewRecord(collection) + record.SetId("w_" + security.PseudorandomString(10)) + record.Set("name", "Test Reaction 2") + record.Set("trigger", "hook") + record.Set("triggerdata", triggerHook) + record.Set("action", "webhook") + record.Set("actiondata", reactionWebhook) + + records = append(records, record) + + return records +} diff --git a/fakedata/records_test.go b/fakedata/records_test.go new file mode 100644 index 0000000..d540ece --- /dev/null +++ b/fakedata/records_test.go @@ -0,0 +1,29 @@ +package fakedata_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/SecurityBrewery/catalyst/fakedata" + catalystTesting "github.com/SecurityBrewery/catalyst/testing" +) + +func Test_records(t *testing.T) { + app, cleanup := catalystTesting.App(t) + defer cleanup() + + got, err := fakedata.Records(app, 2, 2) + require.NoError(t, err) + + assert.Greater(t, len(got), 2) +} + +func TestGenerate(t *testing.T) { + app, cleanup := catalystTesting.App(t) + defer cleanup() + + err := fakedata.Generate(app, 0, 0) + require.NoError(t, err) +} diff --git a/fakedata/text_test.go b/fakedata/text_test.go new file mode 100644 index 0000000..9ffce2f --- /dev/null +++ b/fakedata/text_test.go @@ -0,0 +1,48 @@ +package fakedata + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_fakeTicketComment(t *testing.T) { + assert.NotEmpty(t, fakeTicketComment()) +} + +func Test_fakeTicketDescription(t *testing.T) { + assert.NotEmpty(t, fakeTicketDescription()) +} + +func Test_fakeTicketTask(t *testing.T) { + assert.NotEmpty(t, fakeTicketTask()) +} + +func Test_fakeTicketTimelineMessage(t *testing.T) { + assert.NotEmpty(t, fakeTicketTimelineMessage()) +} + +func Test_random(t *testing.T) { + type args[T any] struct { + e []T + } + + type testCase[T any] struct { + name string + args args[T] + } + + tests := []testCase[int]{ + { + name: "Test random", + args: args[int]{e: []int{1, 2, 3, 4, 5}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := random(tt.args.e) + + assert.Contains(t, tt.args.e, got) + }) + } +} diff --git a/flags.go b/flags.go deleted file mode 100644 index d1aea9d..0000000 --- a/flags.go +++ /dev/null @@ -1,22 +0,0 @@ -package main - -import ( - "github.com/pocketbase/pocketbase/core" - - "github.com/SecurityBrewery/catalyst/migrations" -) - -func flags(app core.App) ([]string, error) { - records, err := app.Dao().FindRecordsByExpr(migrations.FeatureCollectionName) - if err != nil { - return nil, err - } - - var flags []string - - for _, r := range records { - flags = append(flags, r.GetString("name")) - } - - return flags, nil -} diff --git a/go.mod b/go.mod index 8c57446..8cd4e84 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/pocketbase/dbx v1.10.1 github.com/pocketbase/pocketbase v0.22.14 github.com/spf13/cobra v1.8.1 + github.com/stretchr/testify v1.9.0 ) require ( @@ -34,6 +35,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.6 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.28.13 // indirect github.com/aws/smithy-go v1.20.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/disintegration/imaging v1.6.2 // indirect github.com/domodwyer/mailyak/v3 v3.6.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -56,11 +58,11 @@ require ( github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rogpeppe/go-internal v1.10.0 // indirect github.com/spf13/cast v1.6.0 // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/stretchr/testify v1.9.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect go.opencensus.io v0.24.0 // indirect @@ -83,6 +85,7 @@ require ( google.golang.org/grpc v1.64.1 // indirect google.golang.org/protobuf v1.34.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect modernc.org/libc v1.53.3 // indirect modernc.org/mathutil v1.6.0 // indirect diff --git a/go.sum b/go.sum index e35ad6c..21a63f7 100644 --- a/go.sum +++ b/go.sum @@ -330,6 +330,7 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +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.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/main.go b/main.go index 4dc326b..9b6c95e 100644 --- a/main.go +++ b/main.go @@ -3,33 +3,11 @@ package main import ( "log" - "github.com/pocketbase/pocketbase" - - "github.com/SecurityBrewery/catalyst/migrations" + "github.com/SecurityBrewery/catalyst/app" ) func main() { - if err := run(); err != nil { + if err := app.App("./catalyst_data").Start(); err != nil { log.Fatal(err) } } - -func run() error { - migrations.Register() - - app := pocketbase.NewWithConfig(pocketbase.Config{ - DefaultDev: dev(), - DefaultDataDir: "catalyst_data", - }) - - attachWebhooks(app) - - // Register additional commands - app.RootCmd.AddCommand(bootstrapCmd(app)) - app.RootCmd.AddCommand(fakeDataCmd(app)) - app.RootCmd.AddCommand(setFeatureFlagsCmd(app)) - - app.OnBeforeServe().Add(addRoutes()) - - return app.Start() -} diff --git a/migrations/1_collections.go b/migrations/1_collections.go index cdbf058..7cdc8d0 100644 --- a/migrations/1_collections.go +++ b/migrations/1_collections.go @@ -11,15 +11,15 @@ import ( ) const ( - TimelineCollectionName = "timeline" CommentCollectionName = "comments" - fileCollectionName = "files" + FeatureCollectionName = "features" LinkCollectionName = "links" TaskCollectionName = "tasks" TicketCollectionName = "tickets" + TimelineCollectionName = "timeline" TypeCollectionName = "types" WebhookCollectionName = "webhooks" - FeatureCollectionName = "features" + fileCollectionName = "files" UserCollectionName = "_pb_users_auth_" ) @@ -138,14 +138,14 @@ func internalCollection(c *models.Collection) *models.Collection { func collectionsDown(db dbx.Builder) error { collections := []string{ - TicketCollectionName, - TypeCollectionName, fileCollectionName, LinkCollectionName, TaskCollectionName, CommentCollectionName, TimelineCollectionName, FeatureCollectionName, + TicketCollectionName, + TypeCollectionName, } dao := daos.New(db) diff --git a/migrations/5_reactions.go b/migrations/5_reactions.go new file mode 100644 index 0000000..a8693ff --- /dev/null +++ b/migrations/5_reactions.go @@ -0,0 +1,40 @@ +package migrations + +import ( + "fmt" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/daos" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/models/schema" +) + +const ReactionCollectionName = "reactions" + +func reactionsUp(db dbx.Builder) error { + triggers := []string{"webhook", "hook"} + reactions := []string{"python", "webhook"} + + return daos.New(db).SaveCollection(internalCollection(&models.Collection{ + Name: ReactionCollectionName, + Type: models.CollectionTypeBase, + Schema: schema.NewSchema( + &schema.SchemaField{Name: "name", Type: schema.FieldTypeText, Required: true}, + &schema.SchemaField{Name: "trigger", Type: schema.FieldTypeSelect, Required: true, Options: &schema.SelectOptions{MaxSelect: 1, Values: triggers}}, + &schema.SchemaField{Name: "triggerdata", Type: schema.FieldTypeJson, Required: true}, + &schema.SchemaField{Name: "action", Type: schema.FieldTypeSelect, Required: true, Options: &schema.SelectOptions{MaxSelect: 1, Values: reactions}}, + &schema.SchemaField{Name: "actiondata", Type: schema.FieldTypeJson, Required: true}, + ), + })) +} + +func reactionsDown(db dbx.Builder) error { + dao := daos.New(db) + + id, err := dao.FindCollectionByNameOrId(ReactionCollectionName) + if err != nil { + return fmt.Errorf("failed to find collection %s: %w", ReactionCollectionName, err) + } + + return dao.DeleteCollection(id) +} diff --git a/migrations/migrations.go b/migrations/migrations.go index d0ab957..7632d1f 100644 --- a/migrations/migrations.go +++ b/migrations/migrations.go @@ -9,4 +9,5 @@ func Register() { migrations.Register(collectionsUp, collectionsDown, "1700000001_collections.go") migrations.Register(defaultDataUp, nil, "1700000003_defaultdata.go") migrations.Register(viewsUp, viewsDown, "1700000004_views.go") + migrations.Register(reactionsUp, reactionsDown, "1700000005_reactions.go") } diff --git a/reaction/action/action.go b/reaction/action/action.go new file mode 100644 index 0000000..a564134 --- /dev/null +++ b/reaction/action/action.go @@ -0,0 +1,44 @@ +package action + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/SecurityBrewery/catalyst/reaction/action/python" + "github.com/SecurityBrewery/catalyst/reaction/action/webhook" +) + +func Run(ctx context.Context, actionName, actionData, payload string) ([]byte, error) { + action, err := decode(actionName, actionData) + if err != nil { + return nil, err + } + + return action.Run(ctx, payload) +} + +type action interface { + Run(ctx context.Context, payload string) ([]byte, error) +} + +func decode(actionName, actionData string) (action, error) { + switch actionName { + case "python": + var reaction python.Python + if err := json.Unmarshal([]byte(actionData), &reaction); err != nil { + return nil, err + } + + return &reaction, nil + case "webhook": + var reaction webhook.Webhook + if err := json.Unmarshal([]byte(actionData), &reaction); err != nil { + return nil, err + } + + return &reaction, nil + default: + return nil, fmt.Errorf("action %q not found", actionName) + } +} diff --git a/reaction/action/python/python.go b/reaction/action/python/python.go new file mode 100644 index 0000000..1c1cc66 --- /dev/null +++ b/reaction/action/python/python.go @@ -0,0 +1,105 @@ +package python + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "strings" +) + +type Python struct { + Bootstrap string `json:"bootstrap"` + Script string `json:"script"` +} + +func (a *Python) Run(ctx context.Context, payload string) ([]byte, error) { + tempDir, err := os.MkdirTemp("", "catalyst_action") + if err != nil { + return nil, err + } + + defer os.RemoveAll(tempDir) + + if b, err := pythonSetup(ctx, tempDir); err != nil { + var ee *exec.ExitError + if errors.As(err, &ee) { + b = append(b, ee.Stderr...) + } + + return nil, fmt.Errorf("failed to setup python, %w: %s", err, string(b)) + } + + if b, err := pythonRunBootstrap(ctx, tempDir, a.Bootstrap); err != nil { + var ee *exec.ExitError + if errors.As(err, &ee) { + b = append(b, ee.Stderr...) + } + + return nil, fmt.Errorf("failed to run bootstrap, %w: %s", err, string(b)) + } + + b, err := pythonRunScript(ctx, tempDir, a.Script, payload) + if err != nil { + var ee *exec.ExitError + if errors.As(err, &ee) { + b = append(b, ee.Stderr...) + } + + return nil, fmt.Errorf("failed to run script, %w: %s", err, string(b)) + } + + return b, nil +} + +func pythonSetup(ctx context.Context, tempDir string) ([]byte, error) { + pythonPath, err := findExec("python", "python3") + if err != nil { + return nil, fmt.Errorf("python or python3 binary not found, %w", err) + } + + // setup virtual environment + return exec.CommandContext(ctx, pythonPath, "-m", "venv", tempDir+"/venv").Output() +} + +func pythonRunBootstrap(ctx context.Context, tempDir, bootstrap string) ([]byte, error) { + hasBootstrap := len(strings.TrimSpace(bootstrap)) > 0 + + if !hasBootstrap { + return nil, nil + } + + bootstrapPath := tempDir + "/requirements.txt" + + if err := os.WriteFile(bootstrapPath, []byte(bootstrap), 0o600); err != nil { + return nil, err + } + + // install dependencies + pipPath := tempDir + "/venv/bin/pip" + + return exec.CommandContext(ctx, pipPath, "install", "-r", bootstrapPath).Output() +} + +func pythonRunScript(ctx context.Context, tempDir, script, payload string) ([]byte, error) { + scriptPath := tempDir + "/script.py" + + if err := os.WriteFile(scriptPath, []byte(script), 0o600); err != nil { + return nil, err + } + + pythonPath := tempDir + "/venv/bin/python" + + return exec.CommandContext(ctx, pythonPath, scriptPath, payload).Output() +} + +func findExec(name ...string) (string, error) { + for _, n := range name { + if p, err := exec.LookPath(n); err == nil { + return p, nil + } + } + + return "", errors.New("no executable found") +} diff --git a/reaction/action/webhook/payload.go b/reaction/action/webhook/payload.go new file mode 100644 index 0000000..0da9b1c --- /dev/null +++ b/reaction/action/webhook/payload.go @@ -0,0 +1,20 @@ +package webhook + +import ( + "encoding/base64" + "io" + "unicode/utf8" +) + +func EncodeBody(requestBody io.Reader) (string, bool) { + body, err := io.ReadAll(requestBody) + if err != nil { + return "", false + } + + if utf8.Valid(body) { + return string(body), false + } + + return base64.StdEncoding.EncodeToString(body), true +} diff --git a/reaction/action/webhook/payload_test.go b/reaction/action/webhook/payload_test.go new file mode 100644 index 0000000..cb838ee --- /dev/null +++ b/reaction/action/webhook/payload_test.go @@ -0,0 +1,49 @@ +package webhook + +import ( + "bytes" + "io" + "testing" +) + +func TestEncodeBody(t *testing.T) { + type args struct { + requestBody io.Reader + } + + tests := []struct { + name string + args args + want string + want1 bool + }{ + { + name: "utf8", + args: args{ + requestBody: bytes.NewBufferString("body"), + }, + want: "body", + want1: false, + }, + { + name: "non-utf8", + args: args{ + requestBody: bytes.NewBufferString("body\xe0"), + }, + want: "Ym9keeA=", + want1: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, got1 := EncodeBody(tt.args.requestBody) + if got != tt.want { + t.Errorf("EncodeBody() got = %v, want %v", got, tt.want) + } + + if got1 != tt.want1 { + t.Errorf("EncodeBody() got1 = %v, want %v", got1, tt.want1) + } + }) + } +} diff --git a/reaction/action/webhook/response.go b/reaction/action/webhook/response.go new file mode 100644 index 0000000..fca807a --- /dev/null +++ b/reaction/action/webhook/response.go @@ -0,0 +1,12 @@ +package webhook + +import ( + "net/http" +) + +type Response struct { + StatusCode int `json:"statusCode"` + Headers http.Header `json:"headers"` + Body string `json:"body"` + IsBase64Encoded bool `json:"isBase64Encoded"` +} diff --git a/reaction/action/webhook/webhook.go b/reaction/action/webhook/webhook.go new file mode 100644 index 0000000..4bf0c24 --- /dev/null +++ b/reaction/action/webhook/webhook.go @@ -0,0 +1,39 @@ +package webhook + +import ( + "context" + "encoding/json" + "net/http" + "strings" +) + +type Webhook struct { + Headers map[string]string `json:"headers"` + URL string `json:"url"` +} + +func (a *Webhook) Run(ctx context.Context, payload string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.URL, strings.NewReader(payload)) + if err != nil { + return nil, err + } + + for key, value := range a.Headers { + req.Header.Set(key, value) + } + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + body, isBase64Encoded := EncodeBody(res.Body) + + return json.Marshal(Response{ + StatusCode: res.StatusCode, + Headers: res.Header, + Body: body, + IsBase64Encoded: isBase64Encoded, + }) +} diff --git a/reaction/trigger.go b/reaction/trigger.go new file mode 100644 index 0000000..235522c --- /dev/null +++ b/reaction/trigger.go @@ -0,0 +1,13 @@ +package reaction + +import ( + "github.com/pocketbase/pocketbase/core" + + "github.com/SecurityBrewery/catalyst/reaction/trigger/hook" + "github.com/SecurityBrewery/catalyst/reaction/trigger/webhook" +) + +func BindHooks(app core.App) { + hook.BindHooks(app) + webhook.BindHooks(app) +} diff --git a/reaction/trigger/hook/hook.go b/reaction/trigger/hook/hook.go new file mode 100644 index 0000000..36bf048 --- /dev/null +++ b/reaction/trigger/hook/hook.go @@ -0,0 +1,103 @@ +package hook + +import ( + "encoding/json" + "fmt" + "slices" + + "github.com/labstack/echo/v5" + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/apis" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/daos" + "github.com/pocketbase/pocketbase/models" + + "github.com/SecurityBrewery/catalyst/migrations" + "github.com/SecurityBrewery/catalyst/reaction/action" + "github.com/SecurityBrewery/catalyst/webhook" +) + +type Hook struct { + Collections []string `json:"collections"` + Events []string `json:"events"` +} + +func BindHooks(app core.App) { + app.OnRecordAfterCreateRequest().Add(func(e *core.RecordCreateEvent) error { + if err := hook(app.Dao(), "create", e.Collection.Name, e.Record, e.HttpContext); err != nil { + app.Logger().Error("failed to find hook reaction", "error", err.Error()) + } + + return nil + }) + app.OnRecordAfterUpdateRequest().Add(func(e *core.RecordUpdateEvent) error { + if err := hook(app.Dao(), "update", e.Collection.Name, e.Record, e.HttpContext); err != nil { + app.Logger().Error("failed to find hook reaction", "error", err.Error()) + } + + return nil + }) + app.OnRecordAfterDeleteRequest().Add(func(e *core.RecordDeleteEvent) error { + if err := hook(app.Dao(), "delete", e.Collection.Name, e.Record, e.HttpContext); err != nil { + app.Logger().Error("failed to find hook reaction", "error", err.Error()) + } + + return nil + }) +} + +func hook(dao *daos.Dao, event, collection string, record *models.Record, ctx echo.Context) error { + auth, _ := ctx.Get(apis.ContextAuthRecordKey).(*models.Record) + admin, _ := ctx.Get(apis.ContextAdminKey).(*models.Admin) + + hook, found, err := findByHookTrigger(dao, collection, event) + if err != nil { + return fmt.Errorf("failed to find hook reaction: %w", err) + } + + if !found { + return nil + } + + payload, err := json.Marshal(&webhook.Payload{ + Action: event, + Collection: collection, + Record: record, + Auth: auth, + Admin: admin, + }) + if err != nil { + return fmt.Errorf("failed to marshal payload: %w", err) + } + + _, err = action.Run(ctx.Request().Context(), hook.GetString("action"), hook.GetString("actiondata"), string(payload)) + if err != nil { + return fmt.Errorf("failed to run hook reaction: %w", err) + } + + return nil +} + +func findByHookTrigger(dao *daos.Dao, collection, event string) (*models.Record, bool, error) { + records, err := dao.FindRecordsByExpr(migrations.ReactionCollectionName, dbx.HashExp{"trigger": "hook"}) + if err != nil { + return nil, false, err + } + + if len(records) == 0 { + return nil, false, nil + } + + for _, record := range records { + var hook Hook + if err := json.Unmarshal([]byte(record.GetString("triggerdata")), &hook); err != nil { + return nil, false, err + } + + if slices.Contains(hook.Collections, collection) && slices.Contains(hook.Events, event) { + return record, true, nil + } + } + + return nil, false, nil +} diff --git a/reaction/trigger/webhook/request.go b/reaction/trigger/webhook/request.go new file mode 100644 index 0000000..af19c98 --- /dev/null +++ b/reaction/trigger/webhook/request.go @@ -0,0 +1,23 @@ +package webhook + +import ( + "encoding/json" + "net/http" + "net/url" +) + +type Request struct { + Method string `json:"method"` + Path string `json:"path"` + Headers http.Header `json:"headers"` + Query url.Values `json:"query"` + Body string `json:"body"` + IsBase64Encoded bool `json:"isBase64Encoded"` +} + +// isJSON checks if the data is JSON. +func isJSON(data []byte) bool { + var msg json.RawMessage + + return json.Unmarshal(data, &msg) == nil +} diff --git a/reaction/trigger/webhook/request_test.go b/reaction/trigger/webhook/request_test.go new file mode 100644 index 0000000..cb8dbc2 --- /dev/null +++ b/reaction/trigger/webhook/request_test.go @@ -0,0 +1,37 @@ +package webhook + +import "testing" + +func Test_isJSON(t *testing.T) { + type args struct { + data []byte + } + + tests := []struct { + name string + args args + want bool + }{ + { + name: "valid JSON", + args: args{ + data: []byte(`{"key": "value"}`), + }, + want: true, + }, + { + name: "invalid JSON", + args: args{ + data: []byte(`{"key": "value"`), + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isJSON(tt.args.data); got != tt.want { + t.Errorf("isJSON() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/reaction/trigger/webhook/webhook.go b/reaction/trigger/webhook/webhook.go new file mode 100644 index 0000000..b145aca --- /dev/null +++ b/reaction/trigger/webhook/webhook.go @@ -0,0 +1,146 @@ +package webhook + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/labstack/echo/v5" + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/apis" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/daos" + "github.com/pocketbase/pocketbase/models" + + "github.com/SecurityBrewery/catalyst/migrations" + "github.com/SecurityBrewery/catalyst/reaction/action" + "github.com/SecurityBrewery/catalyst/reaction/action/webhook" +) + +type Webhook struct { + Token string `json:"token"` + Path string `json:"path"` +} + +const prefix = "/reaction/" + +func BindHooks(app core.App) { + app.OnBeforeServe().Add(func(e *core.ServeEvent) error { + e.Router.Any(prefix+"*", handle(e.App.Dao())) + + return nil + }) +} + +func handle(dao *daos.Dao) func(c echo.Context) error { + return func(c echo.Context) error { + record, payload, apiErr := parseRequest(dao, c.Request()) + if apiErr != nil { + return apiErr + } + + output, err := action.Run(c.Request().Context(), record.GetString("action"), record.GetString("actiondata"), string(payload)) + if err != nil { + return apis.NewApiError(http.StatusInternalServerError, err.Error(), nil) + } + + return writeOutput(c, output) + } +} + +func parseRequest(dao *daos.Dao, r *http.Request) (*models.Record, []byte, *apis.ApiError) { + if !strings.HasPrefix(r.URL.Path, prefix) { + return nil, nil, apis.NewApiError(http.StatusNotFound, "wrong prefix", nil) + } + + reactionName := strings.TrimPrefix(r.URL.Path, prefix) + + record, trigger, found, err := findByWebhookTrigger(dao, reactionName) + if err != nil { + return nil, nil, apis.NewNotFoundError(err.Error(), nil) + } + + if !found { + return nil, nil, apis.NewNotFoundError("reaction not found", nil) + } + + if trigger.Token != "" { + auth := r.Header.Get("Authorization") + + if !strings.HasPrefix(auth, "Bearer ") { + return nil, nil, apis.NewUnauthorizedError("missing token", nil) + } + + if trigger.Token != strings.TrimPrefix(auth, "Bearer ") { + return nil, nil, apis.NewUnauthorizedError("invalid token", nil) + } + } + + body, isBase64Encoded := webhook.EncodeBody(r.Body) + + payload, err := json.Marshal(&Request{ + Method: r.Method, + Path: r.URL.EscapedPath(), + Headers: r.Header, + Query: r.URL.Query(), + Body: body, + IsBase64Encoded: isBase64Encoded, + }) + if err != nil { + return nil, nil, apis.NewApiError(http.StatusInternalServerError, err.Error(), nil) + } + + return record, payload, nil +} + +func findByWebhookTrigger(dao *daos.Dao, path string) (*models.Record, *Webhook, bool, error) { + records, err := dao.FindRecordsByExpr(migrations.ReactionCollectionName, dbx.HashExp{"trigger": "webhook"}) + if err != nil { + return nil, nil, false, err + } + + if len(records) == 0 { + return nil, nil, false, nil + } + + for _, record := range records { + var webhook Webhook + if err := json.Unmarshal([]byte(record.GetString("triggerdata")), &webhook); err != nil { + return nil, nil, false, err + } + + if webhook.Path == path { + return record, &webhook, true, nil + } + } + + return nil, nil, false, nil +} + +func writeOutput(c echo.Context, output []byte) error { + var catalystResponse webhook.Response + if err := json.Unmarshal(output, &catalystResponse); err == nil && catalystResponse.StatusCode != 0 { + for key, values := range catalystResponse.Headers { + for _, value := range values { + c.Response().Header().Add(key, value) + } + } + + if catalystResponse.IsBase64Encoded { + output, err = base64.StdEncoding.DecodeString(catalystResponse.Body) + if err != nil { + return fmt.Errorf("error decoding base64 body: %w", err) + } + } else { + output = []byte(catalystResponse.Body) + } + } + + if isJSON(output) { + return c.JSON(http.StatusOK, json.RawMessage(output)) + } + + return c.String(http.StatusOK, string(output)) +} diff --git a/testing/collection_reaction_test.go b/testing/collection_reaction_test.go new file mode 100644 index 0000000..995401c --- /dev/null +++ b/testing/collection_reaction_test.go @@ -0,0 +1,250 @@ +package testing + +import ( + "net/http" + "testing" +) + +func TestReactionsCollection(t *testing.T) { + baseApp, adminToken, analystToken, baseAppCleanup := BaseApp(t) + defer baseAppCleanup() + + testSets := []authMatrixText{ + { + baseTest: BaseTest{ + Name: "ListReactions", + Method: http.MethodGet, + URL: "/api/collections/reactions/records", + TestAppFactory: AppFactory(baseApp), + }, + authBasedExpectations: []AuthBasedExpectation{ + { + Name: "Unauthorized", + ExpectedStatus: http.StatusOK, + ExpectedContent: []string{ + `"totalItems":0`, + `"items":[]`, + }, + ExpectedEvents: map[string]int{"OnRecordsListRequest": 1}, + }, + { + Name: "Analyst", + RequestHeaders: map[string]string{"Authorization": analystToken}, + ExpectedStatus: http.StatusOK, + ExpectedContent: []string{ + `"totalItems":3`, + `"id":"r_reaction"`, + }, + NotExpectedContent: []string{ + `"items":[]`, + }, + ExpectedEvents: map[string]int{"OnRecordsListRequest": 1}, + }, + { + Name: "Admin", + RequestHeaders: map[string]string{"Authorization": adminToken}, + ExpectedStatus: http.StatusOK, + ExpectedContent: []string{ + `"totalItems":3`, + `"id":"r_reaction"`, + }, + NotExpectedContent: []string{ + `"items":[]`, + }, + ExpectedEvents: map[string]int{"OnRecordsListRequest": 1}, + }, + }, + }, + { + baseTest: BaseTest{ + Name: "CreateReaction", + Method: http.MethodPost, + RequestHeaders: map[string]string{"Content-Type": "application/json"}, + URL: "/api/collections/reactions/records", + Body: s(map[string]any{ + "name": "test", + "trigger": "webhook", + "triggerdata": map[string]any{"path": "test"}, + "action": "python", + "actiondata": map[string]any{"script": "print('Hello, World!')"}, + }), + TestAppFactory: AppFactory(baseApp), + }, + authBasedExpectations: []AuthBasedExpectation{ + { + Name: "Unauthorized", + ExpectedStatus: http.StatusBadRequest, + ExpectedContent: []string{ + `"message":"Failed to create record."`, + }, + }, + { + Name: "Analyst", + RequestHeaders: map[string]string{"Authorization": analystToken}, + ExpectedStatus: http.StatusOK, + ExpectedContent: []string{ + `"name":"test"`, + }, + NotExpectedContent: []string{ + `"items":[]`, + }, + ExpectedEvents: map[string]int{ + "OnModelAfterCreate": 1, + "OnModelBeforeCreate": 1, + "OnRecordAfterCreateRequest": 1, + "OnRecordBeforeCreateRequest": 1, + }, + }, + { + Name: "Admin", + RequestHeaders: map[string]string{"Authorization": adminToken}, + ExpectedStatus: http.StatusOK, + ExpectedContent: []string{ + `"name":"test"`, + }, + NotExpectedContent: []string{ + `"items":[]`, + }, + ExpectedEvents: map[string]int{ + "OnModelAfterCreate": 1, + "OnModelBeforeCreate": 1, + "OnRecordAfterCreateRequest": 1, + "OnRecordBeforeCreateRequest": 1, + }, + }, + }, + }, + { + baseTest: BaseTest{ + Name: "GetReaction", + Method: http.MethodGet, + RequestHeaders: map[string]string{"Content-Type": "application/json"}, + URL: "/api/collections/reactions/records/r_reaction", + TestAppFactory: AppFactory(baseApp), + }, + authBasedExpectations: []AuthBasedExpectation{ + { + Name: "Unauthorized", + ExpectedStatus: http.StatusNotFound, + ExpectedContent: []string{ + `"message":"The requested resource wasn't found."`, + }, + }, + { + Name: "Analyst", + RequestHeaders: map[string]string{"Authorization": analystToken}, + ExpectedStatus: http.StatusOK, + ExpectedContent: []string{ + `"id":"r_reaction"`, + }, + ExpectedEvents: map[string]int{"OnRecordViewRequest": 1}, + }, + { + Name: "Admin", + RequestHeaders: map[string]string{"Authorization": adminToken}, + ExpectedStatus: http.StatusOK, + ExpectedContent: []string{ + `"id":"r_reaction"`, + }, + ExpectedEvents: map[string]int{"OnRecordViewRequest": 1}, + }, + }, + }, + { + baseTest: BaseTest{ + Name: "UpdateReaction", + Method: http.MethodPatch, + RequestHeaders: map[string]string{"Content-Type": "application/json"}, + URL: "/api/collections/reactions/records/r_reaction", + Body: s(map[string]any{"name": "update"}), + TestAppFactory: AppFactory(baseApp), + }, + authBasedExpectations: []AuthBasedExpectation{ + { + Name: "Unauthorized", + ExpectedStatus: http.StatusNotFound, + ExpectedContent: []string{ + `"message":"The requested resource wasn't found."`, + }, + }, + { + Name: "Analyst", + RequestHeaders: map[string]string{"Authorization": analystToken}, + ExpectedStatus: http.StatusOK, + ExpectedContent: []string{ + `"id":"r_reaction"`, + `"name":"update"`, + }, + ExpectedEvents: map[string]int{ + "OnModelAfterUpdate": 1, + "OnModelBeforeUpdate": 1, + "OnRecordAfterUpdateRequest": 1, + "OnRecordBeforeUpdateRequest": 1, + }, + }, + { + Name: "Admin", + RequestHeaders: map[string]string{"Authorization": adminToken}, + ExpectedStatus: http.StatusOK, + ExpectedContent: []string{ + `"id":"r_reaction"`, + `"name":"update"`, + }, + ExpectedEvents: map[string]int{ + "OnModelAfterUpdate": 1, + "OnModelBeforeUpdate": 1, + "OnRecordAfterUpdateRequest": 1, + "OnRecordBeforeUpdateRequest": 1, + }, + }, + }, + }, + { + baseTest: BaseTest{ + Name: "DeleteReaction", + Method: http.MethodDelete, + URL: "/api/collections/reactions/records/r_reaction", + TestAppFactory: AppFactory(baseApp), + }, + authBasedExpectations: []AuthBasedExpectation{ + { + Name: "Unauthorized", + ExpectedStatus: http.StatusNotFound, + ExpectedContent: []string{ + `"message":"The requested resource wasn't found."`, + }, + }, + { + Name: "Analyst", + RequestHeaders: map[string]string{"Authorization": analystToken}, + ExpectedStatus: http.StatusNoContent, + ExpectedEvents: map[string]int{ + "OnModelAfterDelete": 1, + "OnModelBeforeDelete": 1, + "OnRecordAfterDeleteRequest": 1, + "OnRecordBeforeDeleteRequest": 1, + }, + }, + { + Name: "Admin", + RequestHeaders: map[string]string{"Authorization": adminToken}, + ExpectedStatus: http.StatusNoContent, + ExpectedEvents: map[string]int{ + "OnModelAfterDelete": 1, + "OnModelBeforeDelete": 1, + "OnRecordAfterDeleteRequest": 1, + "OnRecordBeforeDeleteRequest": 1, + }, + }, + }, + }, + } + for _, testSet := range testSets { + t.Run(testSet.baseTest.Name, func(t *testing.T) { + for _, authBasedExpectation := range testSet.authBasedExpectations { + scenario := mergeScenario(testSet.baseTest, authBasedExpectation) + scenario.Test(t) + } + }) + } +} diff --git a/testing/reaction_test.go b/testing/reaction_test.go new file mode 100644 index 0000000..5eb65a3 --- /dev/null +++ b/testing/reaction_test.go @@ -0,0 +1,177 @@ +package testing + +import ( + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestWebhookReactions(t *testing.T) { + baseApp, adminToken, analystToken, baseAppCleanup := BaseApp(t) + defer baseAppCleanup() + + server := testWebhookServer() + defer server.Close() + + go server.ListenAndServe() //nolint:errcheck + + testSets := []authMatrixText{ + { + baseTest: BaseTest{ + Name: "TriggerWebhookReaction", + Method: http.MethodGet, + URL: "/reaction/test", + TestAppFactory: AppFactory(baseApp), + }, + authBasedExpectations: []AuthBasedExpectation{ + { + Name: "Unauthorized", + ExpectedStatus: http.StatusOK, + ExpectedContent: []string{`Hello, World!`}, + }, + { + Name: "Analyst", + RequestHeaders: map[string]string{"Authorization": analystToken}, + ExpectedStatus: http.StatusOK, + ExpectedContent: []string{`Hello, World!`}, + }, + { + Name: "Admin", + RequestHeaders: map[string]string{"Authorization": adminToken}, + ExpectedStatus: http.StatusOK, + ExpectedContent: []string{`Hello, World!`}, + }, + }, + }, + { + baseTest: BaseTest{ + Name: "TriggerWebhookReaction2", + Method: http.MethodGet, + URL: "/reaction/test2", + TestAppFactory: AppFactory(baseApp), + }, + authBasedExpectations: []AuthBasedExpectation{ + { + Name: "Unauthorized", + ExpectedStatus: http.StatusOK, + ExpectedContent: []string{`"test":true`}, + }, + { + Name: "Analyst", + RequestHeaders: map[string]string{"Authorization": analystToken}, + ExpectedStatus: http.StatusOK, + ExpectedContent: []string{`"test":true`}, + }, + { + Name: "Admin", + RequestHeaders: map[string]string{"Authorization": adminToken}, + ExpectedStatus: http.StatusOK, + ExpectedContent: []string{`"test":true`}, + }, + }, + }, + } + for _, testSet := range testSets { + t.Run(testSet.baseTest.Name, func(t *testing.T) { + for _, authBasedExpectation := range testSet.authBasedExpectations { + scenario := mergeScenario(testSet.baseTest, authBasedExpectation) + scenario.Test(t) + } + }) + } +} + +func testWebhookServer() *http.Server { + mux := http.NewServeMux() + mux.HandleFunc("/webhook", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"test":true}`)) //nolint:errcheck + }) + + return &http.Server{ + Addr: "127.0.0.1:12345", + Handler: mux, + ReadHeaderTimeout: 3 * time.Second, + } +} + +type RecordingServer struct { + Entries []string +} + +func NewRecordingServer() *RecordingServer { + return &RecordingServer{} +} + +func (s *RecordingServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.Entries = append(s.Entries, r.URL.Path) + + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"test":true}`)) //nolint:errcheck +} + +func TestHookReactions(t *testing.T) { + baseApp, _, analystToken, baseAppCleanup := BaseApp(t) + defer baseAppCleanup() + + server := NewRecordingServer() + + go http.ListenAndServe("127.0.0.1:12346", server) //nolint:gosec,errcheck + + testSets := []authMatrixText{ + { + baseTest: BaseTest{ + Name: "TriggerHookReaction", + Method: http.MethodPost, + RequestHeaders: map[string]string{"Content-Type": "application/json"}, + URL: "/api/collections/tickets/records", + Body: s(map[string]any{ + "name": "test", + }), + TestAppFactory: AppFactory(baseApp), + }, + authBasedExpectations: []AuthBasedExpectation{ + // { + // Name: "Unauthorized", + // ExpectedStatus: http.StatusOK, + // ExpectedContent: []string{`Hello, World!`}, + // }, + { + Name: "Analyst", + RequestHeaders: map[string]string{"Authorization": analystToken}, + ExpectedStatus: http.StatusOK, + ExpectedContent: []string{ + `"collectionName":"tickets"`, + `"name":"test"`, + }, + ExpectedEvents: map[string]int{ + "OnModelAfterCreate": 1, + "OnModelBeforeCreate": 1, + "OnRecordAfterCreateRequest": 1, + "OnRecordBeforeCreateRequest": 1, + }, + }, + // { + // Name: "Admin", + // RequestHeaders: map[string]string{"Authorization": adminToken}, + // ExpectedStatus: http.StatusOK, + // ExpectedContent: []string{`Hello, World!`}, + // }, + }, + }, + } + for _, testSet := range testSets { + t.Run(testSet.baseTest.Name, func(t *testing.T) { + for _, authBasedExpectation := range testSet.authBasedExpectations { + scenario := mergeScenario(testSet.baseTest, authBasedExpectation) + scenario.Test(t) + } + + require.NotEmpty(t, server.Entries) + }) + } +} diff --git a/testing/routes_test.go b/testing/routes_test.go new file mode 100644 index 0000000..b0c5ad6 --- /dev/null +++ b/testing/routes_test.go @@ -0,0 +1,79 @@ +package testing + +import ( + "net/http" + "testing" +) + +func Test_Routes(t *testing.T) { + baseApp, adminToken, analystToken, baseAppCleanup := BaseApp(t) + defer baseAppCleanup() + + testSets := []authMatrixText{ + { + baseTest: BaseTest{ + Name: "Root", + Method: http.MethodGet, + URL: "/", + TestAppFactory: AppFactory(baseApp), + }, + authBasedExpectations: []AuthBasedExpectation{ + { + Name: "Unauthorized", + ExpectedStatus: http.StatusFound, + }, + { + Name: "Analyst", + RequestHeaders: map[string]string{"Authorization": analystToken}, + ExpectedStatus: http.StatusFound, + }, + { + Name: "Admin", + RequestHeaders: map[string]string{"Authorization": adminToken}, + ExpectedStatus: http.StatusFound, + }, + }, + }, + { + baseTest: BaseTest{ + Name: "Config", + Method: http.MethodGet, + URL: "/api/config", + TestAppFactory: AppFactory(baseApp), + }, + authBasedExpectations: []AuthBasedExpectation{ + { + Name: "Unauthorized", + ExpectedStatus: http.StatusOK, + ExpectedContent: []string{ + `"flags":null`, + }, + }, + { + Name: "Analyst", + RequestHeaders: map[string]string{"Authorization": analystToken}, + ExpectedStatus: http.StatusOK, + ExpectedContent: []string{ + `"flags":null`, + }, + }, + { + Name: "Admin", + RequestHeaders: map[string]string{"Authorization": adminToken}, + ExpectedStatus: http.StatusOK, + ExpectedContent: []string{ + `"flags":null`, + }, + }, + }, + }, + } + for _, testSet := range testSets { + t.Run(testSet.baseTest.Name, func(t *testing.T) { + for _, authBasedExpectation := range testSet.authBasedExpectations { + scenario := mergeScenario(testSet.baseTest, authBasedExpectation) + scenario.Test(t) + } + }) + } +} diff --git a/testing/testdata.go b/testing/testdata.go new file mode 100644 index 0000000..55431f5 --- /dev/null +++ b/testing/testdata.go @@ -0,0 +1,103 @@ +package testing + +import ( + "testing" + + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/models" + + "github.com/SecurityBrewery/catalyst/migrations" +) + +const ( + adminEmail = "admin@catalyst-soar.com" + analystEmail = "analyst@catalyst-soar.com" +) + +func defaultTestData(t *testing.T, app core.App) { + t.Helper() + + adminTestData(t, app) + userTestData(t, app) + reactionTestData(t, app) +} + +func adminTestData(t *testing.T, app core.App) { + t.Helper() + + admin := &models.Admin{Email: adminEmail} + + if err := admin.SetPassword("password"); err != nil { + t.Fatal(err) + } + + if err := app.Dao().SaveAdmin(admin); err != nil { + t.Fatal(err) + } +} + +func userTestData(t *testing.T, app core.App) { + t.Helper() + + collection, err := app.Dao().FindCollectionByNameOrId(migrations.UserCollectionName) + if err != nil { + t.Fatal(err) + } + + record := models.NewRecord(collection) + record.SetId("u_bob_analyst") + _ = record.SetUsername("u_bob_analyst") + _ = record.SetPassword("password") + record.Set("name", "Bob Analyst") + record.Set("email", analystEmail) + _ = record.SetVerified(true) + + if err := app.Dao().SaveRecord(record); err != nil { + t.Fatal(err) + } +} + +func reactionTestData(t *testing.T, app core.App) { + t.Helper() + + collection, err := app.Dao().FindCollectionByNameOrId(migrations.ReactionCollectionName) + if err != nil { + t.Fatal(err) + } + + record := models.NewRecord(collection) + record.SetId("r_reaction") + record.Set("name", "Reaction") + record.Set("trigger", "webhook") + record.Set("triggerdata", `{"path":"test"}`) + record.Set("action", "python") + record.Set("actiondata", `{"bootstrap":"requests","script":"print('Hello, World!')"}`) + + if err := app.Dao().SaveRecord(record); err != nil { + t.Fatal(err) + } + + record = models.NewRecord(collection) + record.SetId("r_reaction_webhook") + record.Set("name", "Reaction") + record.Set("trigger", "webhook") + record.Set("triggerdata", `{"path":"test2"}`) + record.Set("action", "webhook") + record.Set("actiondata", `{"headers":{"Content-Type":"application/json"},"url":"http://127.0.0.1:12345/webhook"}`) + + if err := app.Dao().SaveRecord(record); err != nil { + t.Fatal(err) + } + + record = models.NewRecord(collection) + record.SetId("r_reaction_hook") + record.Set("name", "Hook") + record.Set("trigger", "hook") + record.Set("triggerdata", `{"collections":["tickets"],"events":["create"]}`) + record.Set("action", "python") + record.Set("actiondata", `{"bootstrap":"requests","script":"import requests\nrequests.post('http://127.0.0.1:12346/test', json={'test':True})"}`) + + if err := app.Dao().SaveRecord(record); err != nil { + t.Fatal(err) + } +} diff --git a/testing/testing.go b/testing/testing.go new file mode 100644 index 0000000..2264623 --- /dev/null +++ b/testing/testing.go @@ -0,0 +1,172 @@ +package testing + +import ( + "bytes" + "encoding/json" + "os" + "testing" + + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tests" + "github.com/pocketbase/pocketbase/tokens" + + "github.com/SecurityBrewery/catalyst/app" + "github.com/SecurityBrewery/catalyst/migrations" +) + +func BaseApp(t *testing.T) (core.App, string, string, func()) { + t.Helper() + + temp, err := os.MkdirTemp("", "catalyst_test_data") + if err != nil { + t.Fatal(err) + } + + baseApp := app.App(temp) + + if err := app.Bootstrap(baseApp); err != nil { + t.Fatal(err) + } + + defaultTestData(t, baseApp) + + adminToken, err := generateAdminToken(t, baseApp, adminEmail) + if err != nil { + t.Fatal(err) + } + + analystToken, err := generateRecordToken(t, baseApp, analystEmail) + if err != nil { + t.Fatal(err) + } + + return baseApp, adminToken, analystToken, func() { _ = os.RemoveAll(temp) } +} + +func AppFactory(baseApp core.App) func(t *testing.T) *tests.TestApp { + return func(t *testing.T) *tests.TestApp { + t.Helper() + + testApp, err := tests.NewTestApp(baseApp.DataDir()) + if err != nil { + t.Fatal(err) + } + + app.BindHooks(testApp) + + if err := app.Bootstrap(testApp); err != nil { + t.Fatal(err) + } + + return testApp + } +} + +func App(t *testing.T) (*tests.TestApp, func()) { + t.Helper() + + baseApp, _, _, cleanup := BaseApp(t) + + testApp := AppFactory(baseApp)(t) + + return testApp, cleanup +} + +func generateAdminToken(t *testing.T, baseApp core.App, email string) (string, error) { + t.Helper() + + app, err := tests.NewTestApp(baseApp.DataDir()) + if err != nil { + return "", err + } + defer app.Cleanup() + + admin, err := app.Dao().FindAdminByEmail(email) + if err != nil { + return "", err + } + + return tokens.NewAdminAuthToken(app, admin) +} + +func generateRecordToken(t *testing.T, baseApp core.App, email string) (string, error) { + t.Helper() + + app, err := tests.NewTestApp(baseApp.DataDir()) + if err != nil { + t.Fatal(err) + } + defer app.Cleanup() + + record, err := app.Dao().FindAuthRecordByEmail(migrations.UserCollectionName, email) + if err != nil { + return "", err + } + + return tokens.NewRecordAuthToken(app, record) +} + +type BaseTest struct { + Name string + Method string + RequestHeaders map[string]string + URL string + Body string + TestAppFactory func(t *testing.T) *tests.TestApp +} + +type AuthBasedExpectation struct { + Name string + RequestHeaders map[string]string + ExpectedStatus int + ExpectedContent []string + NotExpectedContent []string + ExpectedEvents map[string]int +} + +type authMatrixText struct { + baseTest BaseTest + authBasedExpectations []AuthBasedExpectation +} + +func mergeScenario(base BaseTest, expectation AuthBasedExpectation) tests.ApiScenario { + return tests.ApiScenario{ + Name: expectation.Name, + Method: base.Method, + Url: base.URL, + Body: bytes.NewBufferString(base.Body), + TestAppFactory: base.TestAppFactory, + + RequestHeaders: mergeMaps(base.RequestHeaders, expectation.RequestHeaders), + ExpectedStatus: expectation.ExpectedStatus, + ExpectedContent: expectation.ExpectedContent, + NotExpectedContent: expectation.NotExpectedContent, + ExpectedEvents: expectation.ExpectedEvents, + } +} + +func mergeMaps(a, b map[string]string) map[string]string { + if a == nil { + return b + } + + if b == nil { + return a + } + + for k, v := range b { + a[k] = v + } + + return a +} + +func b(data map[string]any) []byte { + b, _ := json.Marshal(data) //nolint:errchkjson + + return b +} + +func s(data map[string]any) string { + return string(b(data)) +} diff --git a/ui/src/components/common/DeleteDialog.vue b/ui/src/components/common/DeleteDialog.vue index d95b220..19e98bf 100644 --- a/ui/src/components/common/DeleteDialog.vue +++ b/ui/src/components/common/DeleteDialog.vue @@ -63,11 +63,11 @@ const deleteMutation = useMutation({ > - + + - diff --git a/ui/src/components/form/GrowTextarea.vue b/ui/src/components/form/GrowTextarea.vue new file mode 100644 index 0000000..91f8b52 --- /dev/null +++ b/ui/src/components/form/GrowTextarea.vue @@ -0,0 +1,49 @@ + + +