feat: add reactions (#1074)

This commit is contained in:
Jonas Plum
2024-07-20 06:39:02 +02:00
committed by GitHub
parent 82ad50d228
commit e2c8f1d223
78 changed files with 3270 additions and 257 deletions

View File

@@ -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)
}
})
}
}

177
testing/reaction_test.go Normal file
View File

@@ -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)
})
}
}

79
testing/routes_test.go Normal file
View File

@@ -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)
}
})
}
}

103
testing/testdata.go Normal file
View File

@@ -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)
}
}

172
testing/testing.go Normal file
View File

@@ -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))
}