package rulestest import ( "bytes" "context" "database/sql" "fmt" "io" "net/http" "net/http/httptest" "os" "strings" "testing" "time" "github.com/gin-gonic/gin" _ "github.com/jackc/pgx/v4/stdlib" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "edge-infra.dev/pkg/lib/fog" rulesengine "edge-infra.dev/pkg/sds/emergencyaccess/rules" "edge-infra.dev/pkg/sds/emergencyaccess/rules/server" "edge-infra.dev/pkg/sds/emergencyaccess/rules/storage/database" "edge-infra.dev/test/f2" "edge-infra.dev/test/f2/x/postgres" ) var f f2.Framework func TestMain(m *testing.M) { // Set up test framework in TestMain f = f2.New( context.Background(), f2.WithExtensions( postgres.New(), ), ). BeforeEachTest(func(ctx f2.Context, t *testing.T) (f2.Context, error) { // Create rulesengine tables db := postgres.FromContextT(ctx, t).DB() _, err := db.ExecContext(ctx, schema) if err != nil { return ctx, fmt.Errorf("error creating schema: %w", err) } return ctx, nil }) // Run the tests os.Exit(f.Run(m)) } func TestAdminPrivilegeEndpoints(t *testing.T) { var ( //buf *bytes.Buffer rulesEngine *gin.Engine ) feat := f2.NewFeature("Admin Privilege Rules"). Setup("Create Rules Engine server", func(ctx f2.Context, t *testing.T) f2.Context { var db = postgres.FromContextT(ctx, t).DB() rulesEngine, _ = setupRulesEngine(t, db) _ = rulesEngine return ctx }). Setup("Add some data", func(ctx f2.Context, t *testing.T) f2.Context { var ( db = postgres.FromContextT(ctx, t).DB() ) _, err := db.ExecContext(ctx, priviledgesData) require.NoError(t, err) return ctx }). Test("Get All Privileges", func(ctx f2.Context, t *testing.T) f2.Context { test := testCase{ url: "/admin/privileges", method: http.MethodGet, expectedStatus: http.StatusOK, expectedOut: `[ { "name": "read", "id": "78587bb1-6ca2-4d2d-a223-1ee642514b97" } ]`, } return testEndpoint(ctx, t, rulesEngine, test) }). Feature() // Run the tests f.Test(t, feat) } func setupRulesEngine(t *testing.T, db *sql.DB) (*gin.Engine, *bytes.Buffer) { gin.SetMode(gin.TestMode) router := gin.New() buf := new(bytes.Buffer) log := fog.New(fog.To(buf)) ds := database.New(log, db) re := rulesengine.New(ds) res, err := server.New(router, re, log) require.NoError(t, err) return res.GinEngine, buf } func TestPostDefaultRuleEndpoints(t *testing.T) { var ( rulesEngine *gin.Engine buf *bytes.Buffer ) feat := f2.NewFeature("Admin Privilege Rules"). Setup("Create Rules Engine server", func(ctx f2.Context, t *testing.T) f2.Context { var db = postgres.FromContextT(ctx, t).DB() rulesEngine, buf = setupRulesEngine(t, db) _ = buf return ctx }). Setup("Add some data", func(ctx f2.Context, t *testing.T) f2.Context { var ( db = postgres.FromContextT(ctx, t).DB() ) for _, q := range rulesData.privExs { _, err := db.ExecContext(ctx, q) require.NoError(t, err) } for _, q := range rulesData.commExs { _, err := db.ExecContext(ctx, q) require.NoError(t, err) } return ctx }). Test("Post Rule", func(ctx f2.Context, t *testing.T) f2.Context { // Post ls [basic] test := testCase{ url: "/admin/rules/default/commands", method: http.MethodPost, body: strings.NewReader(`[{"command":"ls","privileges":["basic"]}]`), expectedStatus: http.StatusOK, } ctx = testEndpoint(ctx, t, rulesEngine, test) return ctx }). Test("Read Database State 1", func(ctx f2.Context, t *testing.T) f2.Context { // Expect ls [basic] expectedOutput := fmt.Sprintf(`[ {"command":{"name":"ls","id":"%s"}, "privileges":[{"name":"basic","id":"%s"}]} ]`, rulesData.comms["ls"], rulesData.privs["basic"]) return testDefaultRules(ctx, expectedOutput, rulesEngine, t) }). Test("Post Rule Existing Privilege", func(ctx f2.Context, t *testing.T) f2.Context { // Post ls [read] test := testCase{ url: "/admin/rules/default/commands", method: http.MethodPost, body: strings.NewReader(`[{"command":"ls","privileges":["read"]}]`), expectedStatus: http.StatusOK, } return testEndpoint(ctx, t, rulesEngine, test) }). Test("Read Database State 2", func(ctx f2.Context, t *testing.T) f2.Context { // Expect ls [basic,read] expectedOutput := fmt.Sprintf(`[ { "command":{"name":"ls","id":"%s"}, "privileges":[ {"name":"basic","id":"%s"}, {"name":"read","id":"%s"} ] } ]`, rulesData.comms["ls"], rulesData.privs["basic"], rulesData.privs["read"]) return testDefaultRules(ctx, expectedOutput, rulesEngine, t) }). Test("Post Rule New and Existing Privilege", func(ctx f2.Context, t *testing.T) f2.Context { // Post ls [read,write] test := testCase{ url: "/admin/rules/default/commands", method: http.MethodPost, body: strings.NewReader(`[{"command":"ls","privileges":["read","write"]}]`), expectedStatus: http.StatusOK, } return testEndpoint(ctx, t, rulesEngine, test) }). Test("Read Database State 3", func(ctx f2.Context, t *testing.T) f2.Context { // Expect ls [basic,read,write] expectedOutput := fmt.Sprintf(`[ { "command":{"name":"ls","id":"%s"}, "privileges":[ {"name":"basic","id":"%s"}, {"name":"read","id":"%s"}, {"name":"write","id":"%s"} ] } ]`, rulesData.comms["ls"], rulesData.privs["basic"], rulesData.privs["read"], rulesData.privs["write"]) return testDefaultRules(ctx, expectedOutput, rulesEngine, t) }). Test("Post Rule Existing,Invalid,New Privilege", func(ctx f2.Context, t *testing.T) f2.Context { // Post ls [read,invalid,admin] test := testCase{ url: "/admin/rules/default/commands", method: http.MethodPost, body: strings.NewReader(`[{"command":"ls","privileges":["read","invalid","admin"]}]`), expectedStatus: http.StatusNotFound, expectedOut: `{"errors":[{"privilege":"invalid", "type":"Unknown Privilege"}]}`, } return testEndpoint(ctx, t, rulesEngine, test) }). Test("Read Database State 4", func(ctx f2.Context, t *testing.T) f2.Context { // Expect ls [basic,read,write] -> db state unchanged from read state 3 expectedOutput := fmt.Sprintf(`[ { "command":{"name":"ls","id":"%s"}, "privileges":[ {"name":"basic","id":"%s"}, {"name":"read","id":"%s"}, {"name":"write","id":"%s"} ] } ]`, rulesData.comms["ls"], rulesData.privs["basic"], rulesData.privs["read"], rulesData.privs["write"]) return testDefaultRules(ctx, expectedOutput, rulesEngine, t) }). Test("Post Multiple Rules", func(ctx f2.Context, t *testing.T) f2.Context { // Post ls [admin], echo[basic] test := testCase{ url: "/admin/rules/default/commands", method: http.MethodPost, body: strings.NewReader(`[ {"command":"ls","privileges":["admin"]}, {"command":"echo","privileges":["basic"]} ]`), expectedStatus: http.StatusOK, } return testEndpoint(ctx, t, rulesEngine, test) }). Test("Read Database State 5", func(ctx f2.Context, t *testing.T) f2.Context { // Expect ls [basic,read,write,admin] echo [basic] expectedOutput := fmt.Sprintf(`[ { "command":{"name":"ls","id":"%s"}, "privileges":[ {"name":"basic","id":"%s"}, {"name":"read","id":"%s"}, {"name":"write","id":"%s"}, {"name":"admin","id":"%s"} ] }, { "command":{"name":"echo","id":"%s"}, "privileges":[ {"name":"basic","id":"%s"} ] } ]`, rulesData.comms["ls"], rulesData.privs["basic"], rulesData.privs["read"], rulesData.privs["write"], rulesData.privs["admin"], rulesData.comms["echo"], rulesData.privs["basic"], ) return testDefaultRules(ctx, expectedOutput, rulesEngine, t) }). Test("Post Multiple Rules New and Existing Privilege", func(ctx f2.Context, t *testing.T) f2.Context { // Post ls [admin], echo[admin] test := testCase{ url: "/admin/rules/default/commands", method: http.MethodPost, body: strings.NewReader(`[ {"command":"ls","privileges":["admin"]}, {"command":"echo","privileges":["admin"]} ]`), expectedStatus: http.StatusOK, } ctx = testEndpoint(ctx, t, rulesEngine, test) return ctx }). Test("Read Database State 6", func(ctx f2.Context, t *testing.T) f2.Context { // Expect ls [basic,read,write,admin] echo [basic,admin] expectedOutput := fmt.Sprintf(`[ { "command":{"name":"ls","id":"%s"}, "privileges":[ {"name":"basic","id":"%s"}, {"name":"read","id":"%s"}, {"name":"write","id":"%s"}, {"name":"admin","id":"%s"} ] }, { "command":{"name":"echo","id":"%s"}, "privileges":[ {"name":"basic","id":"%s"}, {"name":"admin","id":"%s"} ] } ]`, rulesData.comms["ls"], rulesData.privs["basic"], rulesData.privs["read"], rulesData.privs["write"], rulesData.privs["admin"], rulesData.comms["echo"], rulesData.privs["basic"], rulesData.privs["admin"], ) return testDefaultRules(ctx, expectedOutput, rulesEngine, t) }). Test("Post Multiple Rules New, Existing and Invalid Privilege", func(ctx f2.Context, t *testing.T) f2.Context { // Post ls [admin, invalid], echo [read] test := testCase{ url: "/admin/rules/default/commands", method: http.MethodPost, body: strings.NewReader(`[ {"command":"ls","privileges":["admin","invalid"]}, {"command":"echo","privileges":["read"]} ]`), expectedStatus: http.StatusNotFound, expectedOut: ` {"errors":[{"privilege": "invalid", "type":"Unknown Privilege"}]}`, } ctx = testEndpoint(ctx, t, rulesEngine, test) return ctx }). Test("Read Database State 7", func(ctx f2.Context, t *testing.T) f2.Context { // Expect ls [basic,read,write,admin] echo [basic,admin] -> same as db read state 6 expectedOutput := fmt.Sprintf(`[ { "command":{"name":"ls","id":"%s"}, "privileges":[ {"name":"basic","id":"%s"}, {"name":"read","id":"%s"}, {"name":"write","id":"%s"}, {"name":"admin","id":"%s"} ] }, { "command":{"name":"echo","id":"%s"}, "privileges":[ {"name":"basic","id":"%s"}, {"name":"admin","id":"%s"} ] } ]`, rulesData.comms["ls"], rulesData.privs["basic"], rulesData.privs["read"], rulesData.privs["write"], rulesData.privs["admin"], rulesData.comms["echo"], rulesData.privs["basic"], rulesData.privs["admin"], ) return testDefaultRules(ctx, expectedOutput, rulesEngine, t) }). Feature() // Run the tests f.Test(t, feat) } const rulesOverviewData = ` INSERT INTO ea_rules_commands (command_id, name) VALUES ('78587bb1-6ca2-4d2d-a223-1ee642514b97', 'ls') ; INSERT INTO banners (banner_edge_id, banner_name) VALUES ('2f9f5965-ed2a-4262-9fd9-9d2d8f8bee8a', 'myBanner') ; INSERT INTO ea_rules_privileges (privilege_id, name) VALUES ('a7c379ea-6e34-4017-8e86-eb545d7856a3', 'ea-read'), ('caedabee-ea7a-4421-a608-ec04106e61da', 'ea-write') ;` func TestReadAllRulesForCommand(t *testing.T) { var ( rulesEngine *gin.Engine buf *bytes.Buffer ) feat := f2.NewFeature("Admin Privilege Rules"). Setup("Create Rules Engine server", func(ctx f2.Context, t *testing.T) f2.Context { var db = postgres.FromContextT(ctx, t).DB() rulesEngine, buf = setupRulesEngine(t, db) _ = buf return ctx }). Setup("Add some data", func(ctx f2.Context, t *testing.T) f2.Context { var ( db = postgres.FromContextT(ctx, t).DB() ) _, err := db.ExecContext(ctx, rulesOverviewData) require.NoError(t, err) return ctx }).Test("Read No Existing Rules", func(ctx f2.Context, t *testing.T) f2.Context { // test the no existing rules for same command before adding any data to the test database test := testCase{ url: "/admin/rules/commands/ls", method: http.MethodGet, expectedStatus: http.StatusOK, expectedOut: `null`, } ctx = testEndpoint(ctx, t, rulesEngine, test) return ctx }).Test("Read no existing default rules", func(ctx f2.Context, t *testing.T) f2.Context { var db = postgres.FromContextT(ctx, t).DB() // now test a default rule and make sure the command is still returned even if no default rules are present _, err := db.ExecContext(ctx, ` INSERT INTO ea_rules (banner_edge_id, command_id, privilege_id) VALUES ('2f9f5965-ed2a-4262-9fd9-9d2d8f8bee8a', '78587bb1-6ca2-4d2d-a223-1ee642514b97', 'a7c379ea-6e34-4017-8e86-eb545d7856a3') ;`) require.NoError(t, err) test := testCase{ url: "/admin/rules/commands/ls", method: http.MethodGet, expectedStatus: http.StatusOK, expectedOut: `{ "command": { "id": "78587bb1-6ca2-4d2d-a223-1ee642514b97", "name": "ls" }, "default": {}, "banners": [ { "banner": { "id": "2f9f5965-ed2a-4262-9fd9-9d2d8f8bee8a", "name": "myBanner" }, "privileges": [ { "id": "a7c379ea-6e34-4017-8e86-eb545d7856a3", "name": "ea-read" } ] } ] }`, } ctx = testEndpoint(ctx, t, rulesEngine, test) return ctx }).Test("Read All Rules", func(ctx f2.Context, t *testing.T) f2.Context { var db = postgres.FromContextT(ctx, t).DB() // add banner rule for banner "myBanner" ls [ea-read] and check both default and banner are returned _, err := db.ExecContext(ctx, ` INSERT INTO ea_rules_default (command_id, privilege_id) VALUES ('78587bb1-6ca2-4d2d-a223-1ee642514b97', 'caedabee-ea7a-4421-a608-ec04106e61da') ;`, ) require.NoError(t, err) test := testCase{ url: "/admin/rules/commands/ls", method: http.MethodGet, expectedStatus: http.StatusOK, expectedOut: `{ "command": { "id": "78587bb1-6ca2-4d2d-a223-1ee642514b97", "name": "ls" }, "default": { "privileges": [ { "id": "caedabee-ea7a-4421-a608-ec04106e61da", "name": "ea-write" } ] }, "banners": [ { "banner": { "id": "2f9f5965-ed2a-4262-9fd9-9d2d8f8bee8a", "name": "myBanner" }, "privileges": [ { "id": "a7c379ea-6e34-4017-8e86-eb545d7856a3", "name": "ea-read" } ] } ] }`, } return testEndpoint(ctx, t, rulesEngine, test) }).Feature() f.Test(t, feat) } func testDefaultRules(ctx f2.Context, expectedOut string, rulesEngine *gin.Engine, t *testing.T) f2.Context { test := testCase{ url: "/admin/rules/default/commands", method: http.MethodGet, expectedStatus: http.StatusOK, expectedOut: expectedOut, } return testEndpoint(ctx, t, rulesEngine, test) } type testCase struct { url string method string body io.Reader expectedStatus int // Expected returned data. If nil or an empty string asserts the result is // empty. If a non-empty string does a json equality on the passed in string // and the return data. If a function obeying the signature // func(object interface{}, msgAndArgs ...interface{}) bool // executes the function with the returned data as object expectedOut any } func testEndpoint(ctx f2.Context, t *testing.T, rulesEngine *gin.Engine, test testCase) f2.Context { t.Helper() r := httptest.NewRecorder() c, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() req, err := http.NewRequestWithContext(c, test.method, test.url, test.body) assert.NoError(t, err) rulesEngine.ServeHTTP(r, req) resp := r.Result() assert.Equal(t, test.expectedStatus, resp.StatusCode) switch assertion := test.expectedOut.(type) { case string: if assertion != "" { assert.JSONEq(t, assertion, r.Body.String()) } else { assert.Empty(t, r.Body.String()) } case func(object interface{}, msgAndArgs ...interface{}) bool: assertion(r.Body.String()) case nil: assert.Empty(t, r.Body.String()) default: assert.Fail(t, "expected string or assertion function") } return ctx } const schema = ` CREATE OR REPLACE FUNCTION trigger_set_timestamp() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TABLE IF NOT EXISTS banners ( banner_edge_id UUID DEFAULT gen_random_uuid() PRIMARY KEY, banner_name text UNIQUE NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS ea_rules_commands( command_id UUID DEFAULT gen_random_uuid() PRIMARY KEY, name text NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS ea_rules_privileges( privilege_id UUID DEFAULT gen_random_uuid() PRIMARY KEY, name text NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS ea_rules( banner_edge_id UUID NOT NULL, command_id UUID NOT NULL, privilege_id UUID NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (banner_edge_id, command_id, privilege_id), FOREIGN KEY (banner_edge_id) REFERENCES banners (banner_edge_id) ON DELETE CASCADE, FOREIGN KEY (command_id) REFERENCES ea_rules_commands (command_id), FOREIGN KEY (privilege_id) REFERENCES ea_rules_privileges (privilege_id) ); CREATE TABLE IF NOT EXISTS ea_rules_default( command_id UUID NOT NULL, privilege_id UUID NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (command_id, privilege_id), FOREIGN KEY (command_id) REFERENCES ea_rules_commands (command_id), FOREIGN KEY (privilege_id) REFERENCES ea_rules_privileges (privilege_id) ); CREATE OR REPLACE TRIGGER ea_rules_commands_timestamp BEFORE UPDATE ON ea_rules_commands FOR EACH ROW EXECUTE PROCEDURE trigger_set_timestamp(); CREATE OR REPLACE TRIGGER ea_rules_privileges_timestamp BEFORE UPDATE ON ea_rules_privileges FOR EACH ROW EXECUTE PROCEDURE trigger_set_timestamp(); CREATE OR REPLACE TRIGGER ea_rules_default_timestamp BEFORE UPDATE ON ea_rules_default FOR EACH ROW EXECUTE PROCEDURE trigger_set_timestamp(); CREATE OR REPLACE TRIGGER ea_rules_timestamp BEFORE UPDATE ON ea_rules FOR EACH ROW EXECUTE PROCEDURE trigger_set_timestamp(); ALTER TABLE ea_rules_commands ADD CONSTRAINT command_name_uniq UNIQUE (name); ALTER TABLE ea_rules_privileges ADD CONSTRAINT privilege_name_uniq UNIQUE (name); DO $$ BEGIN BEGIN DROP TYPE IF EXISTS command_type; CREATE TYPE command_type AS ENUM ('command', 'script'); EXCEPTION WHEN dependent_objects_still_exist THEN RAISE NOTICE 'command types already exists'; END; END; $$; ALTER TABLE ea_rules_commands ADD COLUMN IF NOT EXISTS type command_type NOT NULL DEFAULT 'command', DROP CONSTRAINT IF EXISTS command_name_uniq, DROP CONSTRAINT IF EXISTS command_name_type_uniq, ADD CONSTRAINT command_name_type_uniq UNIQUE (name, type) ; DO $$ BEGIN ALTER TYPE command_type RENAME VALUE 'script' TO 'executable'; EXCEPTION WHEN invalid_parameter_value THEN RAISE notice 'script command_type does not exist'; END; $$ ; ` const ( // uuid constants as uuid1 = "78587bb1-6ca2-4d2d-a223-1ee642514b97" uuid2 = "35cc70eb-689d-49d4-8bd8-fa1cb8b0928f" uuid3 = "79bf815d-8e64-4b01-b12e-1f173a322766" uuid4 = "113f6c32-5501-44ba-9cd5-76530be5aa67" priviledgesData = ` INSERT INTO ea_rules_privileges (privilege_id, name) VALUES ('78587bb1-6ca2-4d2d-a223-1ee642514b97', 'read') ; ` ) type postRulesTestData struct { privs map[string]string // name and id for each privilege comms map[string]string // name and id for each command privExs []string // db executes for privilege table commExs []string // db executes for commands table } func newRulesData(privs, comms map[string]string) postRulesTestData { rd := postRulesTestData{privs: privs, comms: comms} rd.commExs = seedData("ea_rules_commands", comms) rd.privExs = seedData("ea_rules_privileges", privs) return rd } var ( rulesData = newRulesData(map[string]string{ "read": uuid1, "basic": uuid2, "write": uuid3, "admin": uuid4, }, map[string]string{ "ls": uuid1, "echo": uuid2, }, ) ) // generates a list of executes for the database for default rules endpoint tests func seedData(table string, data map[string]string) []string { query := `INSERT INTO %s (%s, name) VALUES ('%s','%s') ;` idtype := "" if table == "ea_rules_commands" { idtype = "command_id" } else { idtype = "privilege_id" } res := []string{} for name, id := range data { res = append(res, fmt.Sprintf(query, table, idtype, id, name)) } return res }