diff --git a/.gitignore b/.gitignore index c05580ea1..4b1146e24 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,8 @@ # # Binaries for programs and plugins *.so -authd +/authd +cmd/authd/authd pam/pam_authd.h pam/pam diff --git a/internal/brokers/broker.go b/internal/brokers/broker.go index 5b4221c37..b75d54341 100644 --- a/internal/brokers/broker.go +++ b/internal/brokers/broker.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "hash/fnv" + "os" "strings" "github.com/godbus/dbus/v5" @@ -58,7 +59,8 @@ func newBroker(ctx context.Context, name, configFile string, bus *dbus.Conn) (b if err != nil { return Broker{}, err } - } else if name != localBrokerName { + } else if _, set := os.LookupEnv("AUTHD_USE_EXAMPLES"); set && name != localBrokerName { + // if the broker does not have a config file and the AUTHD_USE_EXAMPLES env var is set, use the example broker broker, fullName, brandIcon = examplebroker.New(name) } diff --git a/internal/brokers/broker_test.go b/internal/brokers/broker_test.go new file mode 100644 index 000000000..b9149b8f6 --- /dev/null +++ b/internal/brokers/broker_test.go @@ -0,0 +1,293 @@ +package brokers_test + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/ubuntu/authd/internal/brokers" + "github.com/ubuntu/authd/internal/responses" + "github.com/ubuntu/authd/internal/testutils" +) + +func TestNewBroker(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + name string + configFile string + wantErr bool + }{ + "No config means local broker": {name: "local"}, + "Successfully create broker with correct config file": {name: "broker", configFile: "valid"}, + + // General config errors + "Error when config file is invalid": {configFile: "invalid", wantErr: true}, + "Error when config file does not exist": {configFile: "do not exist", wantErr: true}, + + // Missing field errors + "Error when config does not have name field": {configFile: "no_name", wantErr: true}, + "Error when config does not have brand_icon field": {configFile: "no_brand_icon", wantErr: true}, + "Error when config does not have dbus.name field": {configFile: "no_dbus_name", wantErr: true}, + "Error when config does not have dbus.object field": {configFile: "no_dbus_object", wantErr: true}, + "Error when config does not have dbus.interface field": {configFile: "no_dbus_interface", wantErr: true}, + } + for name, tc := range tests { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + + conn, err := testutils.GetSystemBusConnection(t) + require.NoError(t, err, "Setup: could not connect to system bus") + + configDir := filepath.Join(fixturesPath, "valid_brokers") + if tc.wantErr { + configDir = filepath.Join(fixturesPath, "invalid_brokers") + } + if tc.configFile != "" { + tc.configFile = filepath.Join(configDir, tc.configFile) + } + + got, err := brokers.NewBroker(context.Background(), tc.name, tc.configFile, conn) + if tc.wantErr { + require.Error(t, err, "NewBroker should return an error, but did not") + return + } + require.NoError(t, err, "NewBroker should not return an error, but did") + + gotString := fmt.Sprintf("ID: %s\nName: %s\nBrand Icon: %s\n", got.ID, got.Name, got.BrandIconPath) + + wantString := testutils.LoadWithUpdateFromGolden(t, gotString) + require.Equal(t, wantString, gotString, "NewBroker should return the expected broker, but did not") + }) + } +} + +func TestGetAuthenticationModes(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + sessionID string + + wantErr bool + }{ + "Successfully get authentication modes": {sessionID: "success"}, + "Does not error out when no authentication modes are returned": {sessionID: "GAM_empty"}, + + // broker errors + "Error when getting authentication modes": {sessionID: "GAM_error", wantErr: true}, + "Error when broker returns invalid modes": {sessionID: "GAM_invalid", wantErr: true}, + } + for name, tc := range tests { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + + b, _ := newBrokerForTests(t) + + gotModes, err := b.GetAuthenticationModes(context.Background(), tc.sessionID, nil) + if tc.wantErr { + require.Error(t, err, "GetAuthenticationModes should return an error, but did not") + return + } + require.NoError(t, err, "GetAuthenticationModes should not return an error, but did") + + wantModes := testutils.LoadWithUpdateFromGoldenYAML(t, gotModes) + require.Equal(t, wantModes, gotModes, "GetAuthenticationModes should return the expected modes, but did not") + }) + } +} + +func TestSelectAuthenticationMode(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + sessionID string + + wantErr bool + }{ + "Successfully select form authentication mode": {sessionID: "SAM_form_success"}, + "Successfully select qrcode authentication mode": {sessionID: "SAM_qrcode_success"}, + "Successfully select newpassword authentication mode": {sessionID: "SAM_newpassword_success"}, + + // broker errors + "Error when selecting authentication mode": {sessionID: "SAM_error", wantErr: true}, + + /* Layout errors */ + + // Layout type errors + "Error when broker returns no layout": {sessionID: "SAM_no_layout", wantErr: true}, + "Error when broker returns invalid layout type": {sessionID: "SAM_invalid_layout_type", wantErr: true}, + + // Type "form" errors + "Error when broker returns form with no label": {sessionID: "SAM_form_no_label", wantErr: true}, + "Error when broker returns form with invalid entry": {sessionID: "SAM_form_invalid_entry", wantErr: true}, + "Error when broker returns form with invalid wait": {sessionID: "SAM_form_invalid_wait", wantErr: true}, + + // Type "qrcode" errors + "Error when broker returns qrcode with no content": {sessionID: "SAM_qrcode_no_content", wantErr: true}, + "Error when broker returns qrcode with invalid wait": {sessionID: "SAM_qrcode_invalid_wait", wantErr: true}, + + // Type "newpassword" errors + "Error when broker returns newpassword with no label": {sessionID: "SAM_newpassword_no_label", wantErr: true}, + "Error when broker returns newpassword with invalid entry": {sessionID: "SAM_newpassword_invalid_entry", wantErr: true}, + } + for name, tc := range tests { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + + b, _ := newBrokerForTests(t) + + gotUI, err := b.SelectAuthenticationMode(context.Background(), tc.sessionID, "mode1") + if tc.wantErr { + require.Error(t, err, "SelectAuthenticationMode should return an error, but did not") + return + } + require.NoError(t, err, "SelectAuthenticationMode should not return an error, but did") + + wantUI := testutils.LoadWithUpdateFromGoldenYAML(t, gotUI) + require.Equal(t, wantUI, gotUI, "SelectAuthenticationMode should return the expected mode UI, but did not") + }) + } +} + +func TestIsAuthorized(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + sessionID string + sessionIDSecondCall string + + wantAccess string + wantErr bool + wantAccessSecondCall string + wantErrSecondCall bool + }{ + //TODO: Once validation is implemented, add cases to check if the data returned by the broker matches what is expected from the access code. + + "Successfully authorize": {sessionID: "success", wantAccess: responses.AuthAllowed}, + "Successfully authorize after cancelling first call": {sessionID: "IA_second_call", wantAccess: responses.AuthCancelled, sessionIDSecondCall: "success", wantAccessSecondCall: responses.AuthAllowed}, + "Denies authentication when broker times out": {sessionID: "IA_timeout", wantAccess: responses.AuthDenied}, + + "Empty data gets JSON formatted": {sessionID: "IA_empty_data", wantAccess: responses.AuthAllowed}, + + // broker errors + "Error when authorizing": {sessionID: "IA_error", wantErr: true}, + "Error when broker returns invalid access": {sessionID: "IA_invalid", wantErr: true}, + "Error when broker returns invalid data": {sessionID: "IA_invalid_data", wantErr: true}, + "Error when calling IsAuthorized a second time without cancelling": {sessionID: "IA_second_call", wantAccess: responses.AuthAllowed, sessionIDSecondCall: "IA_second_call", wantErrSecondCall: true}, + } + for name, tc := range tests { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + + b, _ := newBrokerForTests(t) + + // Stores the combined output of both calls to IsAuthorized + var firstCallReturn, secondCallReturn string + + var access string + var gotData string + var err error + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan struct{}) + go func() { + defer close(done) + access, gotData, err = b.IsAuthorized(ctx, tc.sessionID, "password") + firstCallReturn = fmt.Sprintf("FIRST CALL:\n\taccess: %s\n\tdata: %s\n\terr: %v\n", access, gotData, err) + if tc.wantErr { + require.Error(t, err, "IsAuthorized should return an error, but did not") + return + } + require.NoError(t, err, "IsAuthorized should not return an error, but did") + }() + + // Give some time for the first call to block + time.Sleep(time.Second) + + if tc.wantAccessSecondCall != "" { + cancel() + // Wait for the cancel to go through + time.Sleep(time.Millisecond) + } + if tc.sessionIDSecondCall != "" { + access, gotData, err := b.IsAuthorized(context.Background(), tc.sessionID, "password") + secondCallReturn = fmt.Sprintf("SECOND CALL:\n\taccess: %s\n\tdata: %s\n\terr: %v\n", access, gotData, err) + if tc.wantErrSecondCall { + require.Error(t, err, "IsAuthorized second call should return an error, but did not") + } else { + require.NoError(t, err, "IsAuthorized second call should not return an error, but did") + } + } + + <-done + if tc.wantErr { + return + } + gotStr := firstCallReturn + secondCallReturn + want := testutils.LoadWithUpdateFromGolden(t, gotStr) + require.Equal(t, want, gotStr, "IsAuthorized should return the expected combined data, but did not") + }) + } +} + +func TestCancelIsAuthorized(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + sessionID string + + wantAnswer string + }{ + "Successfully cancels IsAuthorized": {sessionID: "IA_wait", wantAnswer: responses.AuthCancelled}, + "Call returns denied if not cancelled": {sessionID: "IA_timeout", wantAnswer: responses.AuthDenied}, + } + for name, tc := range tests { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + + b, _ := newBrokerForTests(t) + + var access string + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + access, _, _ = b.IsAuthorized(ctx, tc.sessionID, "password") + close(done) + }() + defer cancel() + + if tc.sessionID == "IA_wait" { + // Give some time for the IsAuthorized routine to start. + time.Sleep(time.Second) + cancel() + } + <-done + require.Equal(t, tc.wantAnswer, access, "IsAuthorized should return the expected access, but did not") + }) + } +} + +func newBrokerForTests(t *testing.T) (b brokers.Broker, cfgPath string) { + t.Helper() + + cfgPath = testutils.StartBusBrokerMock(t) + + conn, err := testutils.GetSystemBusConnection(t) + require.NoError(t, err, "Setup: could not connect to system bus") + t.Cleanup(func() { require.NoError(t, conn.Close(), "Teardown: Failed to close the connection") }) + + b, err = brokers.NewBroker(context.Background(), strings.ReplaceAll(t.Name(), "/", "_"), cfgPath, conn) + require.NoError(t, err, "Setup: could not create broker") + + return b, cfgPath +} diff --git a/internal/brokers/export_test.go b/internal/brokers/export_test.go new file mode 100644 index 000000000..ba68d21ca --- /dev/null +++ b/internal/brokers/export_test.go @@ -0,0 +1,28 @@ +package brokers + +import ( + "context" + + "github.com/godbus/dbus/v5" +) + +// NewBroker exports the private newBroker function for testing purposes. +func NewBroker(ctx context.Context, name, configFile string, bus *dbus.Conn) (Broker, error) { + return newBroker(ctx, name, configFile, bus) +} + +// WithCfgDir uses a dedicated path for the broker config dir. +func WithCfgDir(p string) func(o *options) { + return func(o *options) { + o.brokerCfgDir = p + } +} + +// SetBrokerForSession sets the broker for a given session. +// +// This is to be used only in tests. +func (m *Manager) SetBrokerForSession(b *Broker, sessionID string) { + m.transactionsToBrokerMu.Lock() + m.transactionsToBroker[sessionID] = b + m.transactionsToBrokerMu.Unlock() +} diff --git a/internal/brokers/manager.go b/internal/brokers/manager.go index 51013c475..1b7539bba 100644 --- a/internal/brokers/manager.go +++ b/internal/brokers/manager.go @@ -32,7 +32,8 @@ type Manager struct { type Option func(*options) type options struct { - rootDir string + rootDir string + brokerCfgDir string } // WithRootDir uses a dedicated path for our root. @@ -50,7 +51,8 @@ func NewManager(ctx context.Context, configuredBrokers []string, args ...Option) // Set default options. opts := options{ - rootDir: "/", + rootDir: "/", + brokerCfgDir: "etc/authd/broker.d", } // Apply given args. for _, f := range args { @@ -64,7 +66,7 @@ func NewManager(ctx context.Context, configuredBrokers []string, args ...Option) return m, err } - brokersConfPath := filepath.Join(opts.rootDir, "etc/authd/broker.d") + brokersConfPath := filepath.Join(opts.rootDir, opts.brokerCfgDir) // Select all brokers in ascii order if none is configured if len(configuredBrokers) == 0 { @@ -105,15 +107,17 @@ func NewManager(ctx context.Context, configuredBrokers []string, args ...Option) brokers[b.ID] = &b } - // Add example brokers - for _, n := range []string{"broker foo", "broker bar"} { - b, err := newBroker(ctx, n, "", nil) - if err != nil { - log.Errorf(ctx, "Skipping broker %q is not correctly configured: %v", n, err) - continue + // Add example brokers if AUTHD_USE_EXAMPLES is set + if _, set := os.LookupEnv("AUTHD_USE_EXAMPLES"); set { + for _, n := range []string{"broker foo", "broker bar"} { + b, err := newBroker(ctx, n, "", nil) + if err != nil { + log.Errorf(ctx, "Skipping broker %q is not correctly configured: %v", n, err) + continue + } + brokersOrder = append(brokersOrder, b.ID) + brokers[b.ID] = &b } - brokersOrder = append(brokersOrder, b.ID) - brokers[b.ID] = &b } return &Manager{ diff --git a/internal/brokers/manager_test.go b/internal/brokers/manager_test.go new file mode 100644 index 000000000..b69562f77 --- /dev/null +++ b/internal/brokers/manager_test.go @@ -0,0 +1,275 @@ +package brokers_test + +import ( + "context" + "flag" + "fmt" + "math/rand" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/ubuntu/authd/internal/brokers" + "github.com/ubuntu/authd/internal/testutils" +) + +var ( + fixturesPath = filepath.Join("testdata", "fixtures") +) + +func TestNewManager(t *testing.T) { + tests := map[string]struct { + cfgDir string + noBus bool + + wantErr bool + }{ + "Creates all brokers when config dir only has valid brokers": {cfgDir: "valid_brokers"}, + "Creates only correct brokers when config dir has mixed brokers": {cfgDir: "mixed_brokers"}, + "Creates only local broker when config dir only has invalid ones": {cfgDir: "invalid_brokers"}, + "Creates only local broker when config dir does not exist": {cfgDir: "does/not/exist"}, + "Creates manager even if broker is not exported on dbus": {cfgDir: "not_on_bus"}, + + "Error when can't connect to system bus": {cfgDir: "valid_brokers", noBus: true, wantErr: true}, + "Error when broker config dir is a file": {cfgDir: "file_config_dir", wantErr: true}, + } + for name, tc := range tests { + tc := tc + t.Run(name, func(t *testing.T) { + if tc.noBus { + t.Setenv("DBUS_SYSTEM_BUS_ADDRESS", "/dev/null") + } + + got, err := brokers.NewManager(context.Background(), nil, brokers.WithRootDir(fixturesPath), brokers.WithCfgDir(tc.cfgDir)) + if tc.wantErr { + require.Error(t, err, "NewManager should return an error, but did not") + return + } + require.NoError(t, err, "NewManager should not return an error, but did") + + // Grab the list of broker names from the manager to use as golden file. + var brokers []string + for _, broker := range got.AvailableBrokers() { + brokers = append(brokers, broker.Name) + } + + want := testutils.LoadWithUpdateFromGoldenYAML(t, brokers) + require.Equal(t, want, brokers, "NewManager should return the expected brokers, but did not") + }) + } +} + +func TestSetDefaultBrokerForUser(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + exists bool + + wantErr bool + }{ + "Successfully assigns existent broker to user": {exists: true}, + + "Error when broker does not exist": {wantErr: true}, + } + for name, tc := range tests { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + + m, err := brokers.NewManager(context.Background(), nil, brokers.WithRootDir(fixturesPath), brokers.WithCfgDir("mixed_brokers")) + require.NoError(t, err, "Setup: could not create manager") + + brokers := m.AvailableBrokers() + //nolint:gosec // This just to vary the selected broker in the tests, we don't care about randomness safety. + i := rand.Intn(len(brokers)) + want := brokers[i] + if !tc.exists { + want.ID = "does not exist" + } + + err = m.SetDefaultBrokerForUser(brokers[i].ID, "user") + if tc.wantErr { + require.Error(t, err, "SetDefaultBrokerForUser should return an error, but did not") + return + } + require.NoError(t, err, "SetDefaultBrokerForUser should not return an error, but did") + + got := m.BrokerForUser("user") + require.Equal(t, want.ID, got.ID, "SetDefaultBrokerForUser should have assiged the expected broker, but did not") + }) + } +} + +func TestBrokerForUser(t *testing.T) { + t.Parallel() + + m, err := brokers.NewManager(context.Background(), nil, brokers.WithRootDir(fixturesPath), brokers.WithCfgDir("valid_brokers")) + require.NoError(t, err, "Setup: could not create manager") + + err = m.SetDefaultBrokerForUser("local", "user") + require.NoError(t, err, "Setup: could not set default broker") + + // Broker for user should return the assigned broker + got := m.BrokerForUser("user") + require.Equal(t, "local", got.ID, "BrokerForUser should return the assigned broker, but did not") + + // Broker for user should return nil if no broker is assigned + got = m.BrokerForUser("no_broker") + require.Nil(t, got, "BrokerForUser should return nil if no broker is assigned, but did not") +} + +func TestBrokerFromSessionID(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + sessionID string + + wantBrokerID string + wantErr bool + }{ + "Successfully returns expected broker": {sessionID: "success"}, + "Returns local broker if sessionID is empty": {wantBrokerID: "local"}, + + "Error if broker does not exist": {sessionID: "does not exist", wantErr: true}, + } + for name, tc := range tests { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + + b, cfg := newBrokerForTests(t) + m, err := brokers.NewManager(context.Background(), nil, brokers.WithRootDir(filepath.Dir(cfg)), brokers.WithCfgDir("")) + require.NoError(t, err, "Setup: could not create manager") + + if tc.sessionID == "success" { + // We need to use the ID generated by the mananger. + for _, broker := range m.AvailableBrokers() { + if broker.Name != b.Name { + continue + } + b.ID = broker.ID + } + tc.wantBrokerID = b.ID + m.SetBrokerForSession(&b, tc.sessionID) + } + + got, err := m.BrokerFromSessionID(tc.sessionID) + if tc.wantErr { + require.Error(t, err, "BrokerFromSessionID should return an error, but did not") + return + } + require.NoError(t, err, "BrokerFromSessionID should not return an error, but did") + require.Equal(t, tc.wantBrokerID, got.ID, "BrokerFromSessionID should return the expected broker, but did not") + }) + } +} + +func TestNewSession(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + brokerID string + username string + + wantErr bool + }{ + "Successfully start a new session": {username: "success"}, + + "Error when broker does not exist": {brokerID: "does_not_exist", wantErr: true}, + "Error when broker does not provide an ID": {username: "NS_no_id", wantErr: true}, + "Error when starting a new session": {username: "NS_error", wantErr: true}, + } + for name, tc := range tests { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + + wantBroker, cfg := newBrokerForTests(t) + m, err := brokers.NewManager(context.Background(), nil, brokers.WithRootDir(filepath.Dir(cfg)), brokers.WithCfgDir("")) + require.NoError(t, err, "Setup: could not create manager") + + if tc.brokerID == "" { + // We need to use the ID generated by the mananger. + for _, broker := range m.AvailableBrokers() { + if broker.Name != wantBroker.Name { + continue + } + wantBroker.ID = broker.ID + } + tc.brokerID = wantBroker.ID + } + + gotID, gotEKey, err := m.NewSession(tc.brokerID, tc.username, "some_lang") + if tc.wantErr { + require.Error(t, err, "NewSession should return an error, but did not") + return + } + require.NoError(t, err, "NewSession should not return an error, but did") + + // Replaces the autogenerated part of the ID with a placeholder before saving the file. + gotStr := fmt.Sprintf("ID: %s\nEncryption Key: %s\n", strings.ReplaceAll(gotID, wantBroker.ID, "BROKER_ID"), gotEKey) + wantStr := testutils.LoadWithUpdateFromGolden(t, gotStr) + require.Equal(t, wantStr, gotStr, "NewSession should return the expected session, but did not") + + gotBroker, err := m.BrokerFromSessionID(gotID) + require.NoError(t, err, "NewSession should have assigned a broker for the session, but did not") + require.Equal(t, wantBroker.ID, gotBroker.ID, "BrokerFromSessionID should have assigned the expected broker for the session, but did not") + }) + } +} + +func TestEndSession(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + brokerID string + sessionID string + + wantErr bool + }{ + "Successfully end session": {sessionID: "success"}, + + "Error when broker does not exist": {brokerID: "does not exist", sessionID: "dont matter", wantErr: true}, + "Error when ending session": {sessionID: "ES_error", wantErr: true}, + } + for name, tc := range tests { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + + b, cfg := newBrokerForTests(t) + m, err := brokers.NewManager(context.Background(), nil, brokers.WithRootDir(filepath.Dir(cfg)), brokers.WithCfgDir("")) + require.NoError(t, err, "Setup: could not create manager") + + if tc.brokerID != "does not exist" { + m.SetBrokerForSession(&b, tc.sessionID) + } + + err = m.EndSession(tc.sessionID) + if tc.wantErr { + require.Error(t, err, "EndSession should return an error, but did not") + return + } + require.NoError(t, err, "EndSession should not return an error, but did") + _, err = m.BrokerFromSessionID(tc.sessionID) + require.Error(t, err, "EndSession should have removed the broker from the active transactions, but did not") + }) + } +} + +func TestMain(m *testing.M) { + testutils.InstallUpdateFlag() + flag.Parse() + + // Start system bus mock. + cleanup, err := testutils.StartSystemBusMock() + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + defer cleanup() + + m.Run() +} diff --git a/internal/brokers/testdata/TestGetAuthenticationModes/golden/does_not_error_out_when_no_authentication_modes_are_returned b/internal/brokers/testdata/TestGetAuthenticationModes/golden/does_not_error_out_when_no_authentication_modes_are_returned new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/internal/brokers/testdata/TestGetAuthenticationModes/golden/does_not_error_out_when_no_authentication_modes_are_returned @@ -0,0 +1 @@ +[] diff --git a/internal/brokers/testdata/TestGetAuthenticationModes/golden/successfully_get_authentication_modes b/internal/brokers/testdata/TestGetAuthenticationModes/golden/successfully_get_authentication_modes new file mode 100644 index 000000000..3264a7bdf --- /dev/null +++ b/internal/brokers/testdata/TestGetAuthenticationModes/golden/successfully_get_authentication_modes @@ -0,0 +1,2 @@ +- id: mode1 + label: Mode 1 diff --git a/internal/brokers/testdata/TestIsAuthorized/golden/denies_authentication_when_broker_times_out b/internal/brokers/testdata/TestIsAuthorized/golden/denies_authentication_when_broker_times_out new file mode 100644 index 000000000..77f439999 --- /dev/null +++ b/internal/brokers/testdata/TestIsAuthorized/golden/denies_authentication_when_broker_times_out @@ -0,0 +1,4 @@ +FIRST CALL: + access: denied + data: {"mock_answer": "denied by time out"} + err: diff --git a/internal/brokers/testdata/TestIsAuthorized/golden/empty_data_gets_json_formatted b/internal/brokers/testdata/TestIsAuthorized/golden/empty_data_gets_json_formatted new file mode 100644 index 000000000..7b5cb8116 --- /dev/null +++ b/internal/brokers/testdata/TestIsAuthorized/golden/empty_data_gets_json_formatted @@ -0,0 +1,4 @@ +FIRST CALL: + access: allowed + data: {} + err: diff --git a/internal/brokers/testdata/TestIsAuthorized/golden/error_when_calling_isauthorized_a_second_time_without_cancelling b/internal/brokers/testdata/TestIsAuthorized/golden/error_when_calling_isauthorized_a_second_time_without_cancelling new file mode 100644 index 000000000..9b0778372 --- /dev/null +++ b/internal/brokers/testdata/TestIsAuthorized/golden/error_when_calling_isauthorized_a_second_time_without_cancelling @@ -0,0 +1,8 @@ +FIRST CALL: + access: allowed + data: {"mock_answer": "authentication allowed by timeout"} + err: +SECOND CALL: + access: + data: + err: Broker "TestIsAuthorized_Error_when_calling_IsAuthorized_a_second_time_without_cancelling": IsAuthorized already running for session "IA_second_call" diff --git a/internal/brokers/testdata/TestIsAuthorized/golden/successfully_authorize b/internal/brokers/testdata/TestIsAuthorized/golden/successfully_authorize new file mode 100644 index 000000000..295a44e6a --- /dev/null +++ b/internal/brokers/testdata/TestIsAuthorized/golden/successfully_authorize @@ -0,0 +1,4 @@ +FIRST CALL: + access: allowed + data: {"mock_answer": "authentication allowed by default"} + err: diff --git a/internal/brokers/testdata/TestIsAuthorized/golden/successfully_authorize_after_cancelling_first_call b/internal/brokers/testdata/TestIsAuthorized/golden/successfully_authorize_after_cancelling_first_call new file mode 100644 index 000000000..d90f9ad7a --- /dev/null +++ b/internal/brokers/testdata/TestIsAuthorized/golden/successfully_authorize_after_cancelling_first_call @@ -0,0 +1,8 @@ +FIRST CALL: + access: cancelled + data: {"mock_answer": "cancelled by user"} + err: +SECOND CALL: + access: allowed + data: {"mock_answer": "authentication allowed by timeout"} + err: diff --git a/internal/brokers/testdata/TestNewBroker/golden/no_config_means_local_broker b/internal/brokers/testdata/TestNewBroker/golden/no_config_means_local_broker new file mode 100644 index 000000000..3a1a1e3dc --- /dev/null +++ b/internal/brokers/testdata/TestNewBroker/golden/no_config_means_local_broker @@ -0,0 +1,3 @@ +ID: local +Name: local +Brand Icon: diff --git a/internal/brokers/testdata/TestNewBroker/golden/successfully_create_broker_with_correct_config_file b/internal/brokers/testdata/TestNewBroker/golden/successfully_create_broker_with_correct_config_file new file mode 100644 index 000000000..a92c95fb8 --- /dev/null +++ b/internal/brokers/testdata/TestNewBroker/golden/successfully_create_broker_with_correct_config_file @@ -0,0 +1,3 @@ +ID: 2490238132 +Name: Broker +Brand Icon: some_icon.png diff --git a/internal/brokers/testdata/TestNewManager/golden/creates_all_brokers_when_config_dir_only_has_valid_brokers b/internal/brokers/testdata/TestNewManager/golden/creates_all_brokers_when_config_dir_only_has_valid_brokers new file mode 100644 index 000000000..ea65ca46d --- /dev/null +++ b/internal/brokers/testdata/TestNewManager/golden/creates_all_brokers_when_config_dir_only_has_valid_brokers @@ -0,0 +1,3 @@ +- local +- Broker +- Broker2 diff --git a/internal/brokers/testdata/TestNewManager/golden/creates_manager_even_if_broker_is_not_exported_on_dbus b/internal/brokers/testdata/TestNewManager/golden/creates_manager_even_if_broker_is_not_exported_on_dbus new file mode 100644 index 000000000..a1d67c142 --- /dev/null +++ b/internal/brokers/testdata/TestNewManager/golden/creates_manager_even_if_broker_is_not_exported_on_dbus @@ -0,0 +1,2 @@ +- local +- OfflineBroker diff --git a/internal/brokers/testdata/TestNewManager/golden/creates_only_correct_brokers_when_config_dir_has_mixed_brokers b/internal/brokers/testdata/TestNewManager/golden/creates_only_correct_brokers_when_config_dir_has_mixed_brokers new file mode 100644 index 000000000..feb1af9ab --- /dev/null +++ b/internal/brokers/testdata/TestNewManager/golden/creates_only_correct_brokers_when_config_dir_has_mixed_brokers @@ -0,0 +1,2 @@ +- local +- Broker diff --git a/internal/brokers/testdata/TestNewManager/golden/creates_only_local_broker_when_config_dir_does_not_exist b/internal/brokers/testdata/TestNewManager/golden/creates_only_local_broker_when_config_dir_does_not_exist new file mode 100644 index 000000000..70420ab2b --- /dev/null +++ b/internal/brokers/testdata/TestNewManager/golden/creates_only_local_broker_when_config_dir_does_not_exist @@ -0,0 +1 @@ +- local diff --git a/internal/brokers/testdata/TestNewManager/golden/creates_only_local_broker_when_config_dir_only_has_invalid_ones b/internal/brokers/testdata/TestNewManager/golden/creates_only_local_broker_when_config_dir_only_has_invalid_ones new file mode 100644 index 000000000..70420ab2b --- /dev/null +++ b/internal/brokers/testdata/TestNewManager/golden/creates_only_local_broker_when_config_dir_only_has_invalid_ones @@ -0,0 +1 @@ +- local diff --git a/internal/brokers/testdata/TestNewSession/golden/successfully_start_a_new_session b/internal/brokers/testdata/TestNewSession/golden/successfully_start_a_new_session new file mode 100644 index 000000000..e7145f8a7 --- /dev/null +++ b/internal/brokers/testdata/TestNewSession/golden/successfully_start_a_new_session @@ -0,0 +1,2 @@ +ID: BROKER_ID-TestNewSession_Successfully_start_a_new_session-success_session_id +Encryption Key: TestNewSession_Successfully_start_a_new_session_key diff --git a/internal/brokers/testdata/TestSelectAuthenticationMode/golden/successfully_select_form_authentication_mode b/internal/brokers/testdata/TestSelectAuthenticationMode/golden/successfully_select_form_authentication_mode new file mode 100644 index 000000000..e625073b2 --- /dev/null +++ b/internal/brokers/testdata/TestSelectAuthenticationMode/golden/successfully_select_form_authentication_mode @@ -0,0 +1,5 @@ +button: "" +entry: chars_password +label: Success form +type: form +wait: "" diff --git a/internal/brokers/testdata/TestSelectAuthenticationMode/golden/successfully_select_newpassword_authentication_mode b/internal/brokers/testdata/TestSelectAuthenticationMode/golden/successfully_select_newpassword_authentication_mode new file mode 100644 index 000000000..85e46b44d --- /dev/null +++ b/internal/brokers/testdata/TestSelectAuthenticationMode/golden/successfully_select_newpassword_authentication_mode @@ -0,0 +1,4 @@ +button: "" +entry: chars_password +label: Success newpassword +type: newpassword diff --git a/internal/brokers/testdata/TestSelectAuthenticationMode/golden/successfully_select_qrcode_authentication_mode b/internal/brokers/testdata/TestSelectAuthenticationMode/golden/successfully_select_qrcode_authentication_mode new file mode 100644 index 000000000..d5958f765 --- /dev/null +++ b/internal/brokers/testdata/TestSelectAuthenticationMode/golden/successfully_select_qrcode_authentication_mode @@ -0,0 +1,6 @@ +button: "" +content: Success QRCode +entry: "" +label: "" +type: qrcode +wait: "true" diff --git a/internal/brokers/testdata/fixtures/file_config_dir b/internal/brokers/testdata/fixtures/file_config_dir new file mode 100644 index 000000000..5484e2614 --- /dev/null +++ b/internal/brokers/testdata/fixtures/file_config_dir @@ -0,0 +1 @@ +This should not be a file. diff --git a/internal/brokers/testdata/fixtures/invalid_brokers/invalid b/internal/brokers/testdata/fixtures/invalid_brokers/invalid new file mode 100644 index 000000000..b12c8b6c4 --- /dev/null +++ b/internal/brokers/testdata/fixtures/invalid_brokers/invalid @@ -0,0 +1 @@ +badly configured broker diff --git a/internal/brokers/testdata/fixtures/invalid_brokers/no_brand_icon b/internal/brokers/testdata/fixtures/invalid_brokers/no_brand_icon new file mode 100644 index 000000000..1b82f70a8 --- /dev/null +++ b/internal/brokers/testdata/fixtures/invalid_brokers/no_brand_icon @@ -0,0 +1,6 @@ +name = Broker + +[dbus] +name = com.ubuntu.authd.Broker +object = /com/ubuntu/authd/Broker +interface = com.ubuntu.authd.Broker diff --git a/internal/brokers/testdata/fixtures/invalid_brokers/no_dbus_interface b/internal/brokers/testdata/fixtures/invalid_brokers/no_dbus_interface new file mode 100644 index 000000000..212159b15 --- /dev/null +++ b/internal/brokers/testdata/fixtures/invalid_brokers/no_dbus_interface @@ -0,0 +1,6 @@ +name = Broker +brand_icon = some_icon.png + +[dbus] +name = com.ubuntu.authd.Broker +object = /com/ubuntu/authd/Broker diff --git a/internal/brokers/testdata/fixtures/invalid_brokers/no_dbus_name b/internal/brokers/testdata/fixtures/invalid_brokers/no_dbus_name new file mode 100644 index 000000000..509867f1a --- /dev/null +++ b/internal/brokers/testdata/fixtures/invalid_brokers/no_dbus_name @@ -0,0 +1,6 @@ +name = Broker +brand_icon = some_icon.png + +[dbus] +object = /com/ubuntu/authd/Broker +interface = com.ubuntu.authd.Broker diff --git a/internal/brokers/testdata/fixtures/invalid_brokers/no_dbus_object b/internal/brokers/testdata/fixtures/invalid_brokers/no_dbus_object new file mode 100644 index 000000000..607f64415 --- /dev/null +++ b/internal/brokers/testdata/fixtures/invalid_brokers/no_dbus_object @@ -0,0 +1,6 @@ +name = Broker +brand_icon = some_icon.png + +[dbus] +name = com.ubuntu.authd.Broker +interface = com.ubuntu.authd.Broker diff --git a/internal/brokers/testdata/fixtures/invalid_brokers/no_name b/internal/brokers/testdata/fixtures/invalid_brokers/no_name new file mode 100644 index 000000000..592ba10e2 --- /dev/null +++ b/internal/brokers/testdata/fixtures/invalid_brokers/no_name @@ -0,0 +1,6 @@ +brand_icon = some_icon.png + +[dbus] +name = com.ubuntu.authd.Broker +object = /com/ubuntu/authd/Broker +interface = com.ubuntu.authd.Broker diff --git a/internal/brokers/testdata/fixtures/mixed_brokers/invalid b/internal/brokers/testdata/fixtures/mixed_brokers/invalid new file mode 100644 index 000000000..b12c8b6c4 --- /dev/null +++ b/internal/brokers/testdata/fixtures/mixed_brokers/invalid @@ -0,0 +1 @@ +badly configured broker diff --git a/internal/brokers/testdata/fixtures/mixed_brokers/valid b/internal/brokers/testdata/fixtures/mixed_brokers/valid new file mode 100644 index 000000000..0ec3c3b8a --- /dev/null +++ b/internal/brokers/testdata/fixtures/mixed_brokers/valid @@ -0,0 +1,7 @@ +name = Broker +brand_icon = some_icon.png + +[dbus] +name = com.ubuntu.authd.Broker +object = /com/ubuntu/authd/Broker +interface = com.ubuntu.authd.Broker diff --git a/internal/brokers/testdata/fixtures/no_brokers/.empty b/internal/brokers/testdata/fixtures/no_brokers/.empty new file mode 100644 index 000000000..e69de29bb diff --git a/internal/brokers/testdata/fixtures/not_on_bus/not_on_bus b/internal/brokers/testdata/fixtures/not_on_bus/not_on_bus new file mode 100644 index 000000000..d42ac3127 --- /dev/null +++ b/internal/brokers/testdata/fixtures/not_on_bus/not_on_bus @@ -0,0 +1,7 @@ +name = OfflineBroker +brand_icon = some_icon.png + +[dbus] +name = not.exported.onbus.OfflineBroker +object = /not/exported/onbus/Broker +interface = not.exported.onbus.OfflineBroker diff --git a/internal/brokers/testdata/fixtures/valid_brokers/valid b/internal/brokers/testdata/fixtures/valid_brokers/valid new file mode 100644 index 000000000..0ec3c3b8a --- /dev/null +++ b/internal/brokers/testdata/fixtures/valid_brokers/valid @@ -0,0 +1,7 @@ +name = Broker +brand_icon = some_icon.png + +[dbus] +name = com.ubuntu.authd.Broker +object = /com/ubuntu/authd/Broker +interface = com.ubuntu.authd.Broker diff --git a/internal/brokers/testdata/fixtures/valid_brokers/valid_2 b/internal/brokers/testdata/fixtures/valid_brokers/valid_2 new file mode 100644 index 000000000..0f7f0861f --- /dev/null +++ b/internal/brokers/testdata/fixtures/valid_brokers/valid_2 @@ -0,0 +1,7 @@ +name = Broker2 +brand_icon = some_icon.png + +[dbus] +name = com.ubuntu.authd.Broker2 +object = /com/ubuntu/authd/Broker2 +interface = com.ubuntu.authd.Broker2 diff --git a/internal/testutils/broker.go b/internal/testutils/broker.go new file mode 100644 index 000000000..a50ae1afe --- /dev/null +++ b/internal/testutils/broker.go @@ -0,0 +1,297 @@ +package testutils + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/godbus/dbus/v5" + "github.com/godbus/dbus/v5/introspect" + "github.com/stretchr/testify/require" +) + +const ( + objectPathFmt = "/com/ubuntu/authd/%s" + interfaceFmt = "com.ubuntu.authd.%s" +) + +var brokerConfigTemplate = `name = %s +brand_icon = mock_icon.png + +[dbus] +name = com.ubuntu.authd.%s +object = /com/ubuntu/authd/%s +interface = com.ubuntu.authd.%s +` + +type isAuthorizedCtx struct { + ctx context.Context + cancelFunc context.CancelFunc +} + +// BrokerBusMock is the D-Bus object that will answer calls for the broker mock. +type BrokerBusMock struct { + name string + isAuthorizedCalls map[string]isAuthorizedCtx + isAuthorizedCallsMu sync.RWMutex +} + +// StartBusBrokerMock starts the D-Bus service and exports it on the system bus. +// It returns the configuration file path for the exported broker. +func StartBusBrokerMock(t *testing.T) string { + t.Helper() + + brokerName := strings.ReplaceAll(t.Name(), "/", "_") + busObjectPath := fmt.Sprintf(objectPathFmt, brokerName) + busInterface := fmt.Sprintf(interfaceFmt, brokerName) + + conn, err := dbus.ConnectSystemBus() + require.NoError(t, err, "Setup: could not connect to system bus") + t.Cleanup(func() { require.NoError(t, conn.Close(), "Teardown: could not close system bus connection") }) + + bus := BrokerBusMock{ + name: brokerName, + isAuthorizedCalls: map[string]isAuthorizedCtx{}, + isAuthorizedCallsMu: sync.RWMutex{}, + } + err = conn.Export(&bus, dbus.ObjectPath(busObjectPath), busInterface) + require.NoError(t, err, "Setup: could not export mock broker") + + err = conn.Export(introspect.NewIntrospectable(&introspect.Node{ + Name: busObjectPath, + Interfaces: []introspect.Interface{ + introspect.IntrospectData, + { + Name: busInterface, + Methods: introspect.Methods(&bus), + }, + }, + }), dbus.ObjectPath(busObjectPath), introspect.IntrospectData.Name) + require.NoError(t, err, "Setup: could not export mock broker introspection") + + reply, err := conn.RequestName(busInterface, dbus.NameFlagDoNotQueue) + require.NoError(t, err, "Setup: could not request mock broker name") + require.Equal(t, reply, dbus.RequestNameReplyPrimaryOwner, "Setup: mock broker name already taken") + + configPath := writeConfig(t, brokerName) + + t.Cleanup(func() { + r, err := conn.ReleaseName(busInterface) + require.NoError(t, err, "Teardown: could not release mock broker name") + require.Equal(t, r, dbus.ReleaseNameReplyReleased, "Teardown: mock broker name not released") + }) + + return configPath +} + +func writeConfig(t *testing.T, name string) string { + t.Helper() + + cfgPath := filepath.Join(t.TempDir(), "broker-cfg") + + s := fmt.Sprintf(brokerConfigTemplate, name, name, name, name) + err := os.WriteFile(cfgPath, []byte(s), 0600) + require.NoError(t, err, "Setup: Failed to write broker config file") + + return cfgPath +} + +// NewSession returns default values to be used in tests or an error if requested. +func (b *BrokerBusMock) NewSession(username, lang string) (sessionID, encryptionKey string, dbusErr *dbus.Error) { + if username == "NS_error" { + return "", "", dbus.MakeFailedError(fmt.Errorf("Broker %q: NewSession errored out", b.name)) + } + if username == "NS_no_id" { + return "", username + "_key", nil + } + return fmt.Sprintf("%s-%s_session_id", b.name, username), b.name + "_key", nil +} + +// GetAuthenticationModes returns default values to be used in tests or an error if requested. +func (b *BrokerBusMock) GetAuthenticationModes(sessionID string, supportedUILayouts []map[string]string) (authenticationModes []map[string]string, dbusErr *dbus.Error) { + switch sessionID { + case "GAM_invalid": + return []map[string]string{ + {"invalid": "invalid"}, + }, nil + + case "GAM_empty": + return nil, nil + + case "GAM_error": + return nil, dbus.MakeFailedError(fmt.Errorf("Broker %q: GetAuthenticationModes errored out", b.name)) + } + + return []map[string]string{ + {"id": "mode1", "label": "Mode 1"}, + }, nil +} + +// SelectAuthenticationMode returns default values to be used in tests or an error if requested. +func (b *BrokerBusMock) SelectAuthenticationMode(sessionID, authenticationModeName string) (uiLayoutInfo map[string]string, dbusErr *dbus.Error) { + switch sessionID { + case "SAM_invalid_layout_type": + return map[string]string{ + "invalid": "invalid", + }, nil + case "SAM_no_layout": + return nil, nil + case "SAM_error": + return nil, dbus.MakeFailedError(fmt.Errorf("Broker %q: SelectAuthenticationMode errored out", b.name)) + + case "SAM_form_no_label": + return map[string]string{ + "type": "form", + "label": "", + "entry": "chars_password", + }, nil + case "SAM_form_invalid_entry": + return map[string]string{ + "type": "form", + "label": "Invalid Entry", + "entry": "invalid", + }, nil + case "SAM_form_invalid_wait": + return map[string]string{ + "type": "form", + "label": "Invalid Wait", + "entry": "chars_password", + "wait": "invalid", + }, nil + case "SAM_form_success": + return map[string]string{ + "type": "form", + "label": "Success form", + "entry": "chars_password", + }, nil + case "SAM_qrcode_no_content": + return map[string]string{ + "type": "qrcode", + "content": "", + }, nil + case "SAM_qrcode_invalid_wait": + return map[string]string{ + "type": "qrcode", + "content": "Invalid Wait", + "wait": "invalid", + }, nil + case "SAM_qrcode_success": + return map[string]string{ + "type": "qrcode", + "content": "Success QRCode", + "wait": "true", + }, nil + case "SAM_newpassword_no_label": + return map[string]string{ + "type": "newpassword", + "label": "", + "entry": "chars_password", + }, nil + case "SAM_newpassword_invalid_entry": + return map[string]string{ + "type": "newpassword", + "label": "Invalid Entry", + "entry": "invalid", + }, nil + case "SAM_newpassword_success": + return map[string]string{ + "type": "newpassword", + "label": "Success newpassword", + "entry": "chars_password", + }, nil + default: + return map[string]string{}, nil + } +} + +// IsAuthorized returns default values to be used in tests or an error if requested. +func (b *BrokerBusMock) IsAuthorized(sessionID, authenticationData string) (access, data string, dbusErr *dbus.Error) { + if sessionID == "IA_error" { + return "", "", dbus.MakeFailedError(fmt.Errorf("Broker %q: IsAuthorized errored out", b.name)) + } + + // Handles the context that will be assigned for the IsAuthorized handler + b.isAuthorizedCallsMu.RLock() + if _, exists := b.isAuthorizedCalls[sessionID]; exists { + b.isAuthorizedCallsMu.RUnlock() + return "", "", dbus.MakeFailedError(fmt.Errorf("Broker %q: IsAuthorized already running for session %q", b.name, sessionID)) + } + b.isAuthorizedCallsMu.RUnlock() + + ctx, cancel := context.WithCancel(context.Background()) + b.isAuthorizedCallsMu.Lock() + b.isAuthorizedCalls[sessionID] = isAuthorizedCtx{ctx, cancel} + b.isAuthorizedCallsMu.Unlock() + + // Cleans the call after it's done + defer func() { + b.isAuthorizedCallsMu.Lock() + delete(b.isAuthorizedCalls, sessionID) + b.isAuthorizedCallsMu.Unlock() + }() + + access = "allowed" + data = `{"mock_answer": "authentication allowed by default"}` + if sessionID == "IA_invalid" { + access = "invalid" + } + + done := make(chan struct{}) + go func() { + switch sessionID { + case "IA_timeout": + time.Sleep(time.Second) + access = "denied" + data = `{"mock_answer": "denied by time out"}` + case "IA_wait": + <-ctx.Done() + access = "cancelled" + data = `{"mock_answer": "cancelled by user"}` + case "IA_second_call": + select { + case <-ctx.Done(): + access = "cancelled" + data = `{"mock_answer": "cancelled by user"}` + case <-time.After(2 * time.Second): + access = "allowed" + data = `{"mock_answer": "authentication allowed by timeout"}` + } + } + //TODO: Add cases for the new access types + close(done) + }() + <-done + + if sessionID == "IA_invalid_data" { + data = "invalid" + } else if sessionID == "IA_empty_data" { + data = "" + } + + return access, data, nil +} + +// EndSession returns default values to be used in tests or an error if requested. +func (b *BrokerBusMock) EndSession(sessionID string) (dbusErr *dbus.Error) { + if sessionID == "ES_error" { + return dbus.MakeFailedError(fmt.Errorf("Broker %q: EndSession errored out", b.name)) + } + return nil +} + +// CancelIsAuthorized cancels an ongoing IsAuthorized call if it exists. +func (b *BrokerBusMock) CancelIsAuthorized(sessionID string) (dbusErr *dbus.Error) { + b.isAuthorizedCallsMu.Lock() + defer b.isAuthorizedCallsMu.Unlock() + if _, exists := b.isAuthorizedCalls[sessionID]; !exists { + return nil + } + b.isAuthorizedCalls[sessionID].cancelFunc() + delete(b.isAuthorizedCalls, sessionID) + return nil +} diff --git a/internal/testutils/dbus.go b/internal/testutils/dbus.go new file mode 100644 index 000000000..8c6ad85bd --- /dev/null +++ b/internal/testutils/dbus.go @@ -0,0 +1,102 @@ +// Package testutils provides utility functions and behaviors for testing. +package testutils + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/godbus/dbus/v5" +) + +const defaultSystemBusAddress = "unix:path=/var/run/dbus/system_bus_socket" + +var systemBusMockCfg = ` + + system + + unix:path=%s + + + + + + + +` + +// StartSystemBusMock starts a mock dbus daemon and returns a cancel function to stop it. +// +// This function uses t.Setenv to set the DBUS_SYSTEM_BUS_ADDRESS environment, so it shouldn't be used in parallel tests +// that rely on the mentioned variable. +func StartSystemBusMock() (func(), error) { + if isRunning() { + return nil, errors.New("system bus mock is already running") + } + + tmp, err := os.MkdirTemp(os.TempDir(), "authd-system-bus-mock") + if err != nil { + return nil, err + } + + cfgPath := filepath.Join(tmp, "bus.conf") + listenPath := filepath.Join(tmp, "bus.sock") + + err = os.WriteFile(cfgPath, []byte(fmt.Sprintf(systemBusMockCfg, listenPath)), 0600) + if err != nil { + err = errors.Join(err, os.RemoveAll(tmp)) + return nil, err + } + + busCtx, busCancel := context.WithCancel(context.Background()) + //#nosec:G204 // This is a test helper and we are in control of the arguments. + cmd := exec.CommandContext(busCtx, "dbus-daemon", "--config-file="+cfgPath) + if err := cmd.Start(); err != nil { + busCancel() + err = errors.Join(err, os.RemoveAll(tmp)) + return nil, err + } + // Give some time for the daemon to start. + time.Sleep(5 * time.Millisecond) + + prev, set := os.LookupEnv("DBUS_SYSTEM_BUS_ADDRESS") + os.Setenv("DBUS_SYSTEM_BUS_ADDRESS", "unix:path="+listenPath) + + return func() { + busCancel() + _ = cmd.Wait() + _ = os.RemoveAll(tmp) + + if !set { + os.Unsetenv("DBUS_SYSTEM_BUS_ADDRESS") + } else { + os.Setenv("DBUS_SYSTEM_BUS_ADDRESS", prev) + } + }, nil +} + +// GetSystemBusConnection returns a connection to the system bus with a safety check to avoid mistakenly connecting to the +// actual system bus. +func GetSystemBusConnection(t *testing.T) (*dbus.Conn, error) { + t.Helper() + if !isRunning() { + return nil, errors.New("system bus mock is not running. If that's intended, manually connect to the system bus instead of using this function") + } + conn, err := dbus.ConnectSystemBus() + if err != nil { + return nil, err + } + return conn, nil +} + +// isRunning checks if the system bus mock is running. +func isRunning() bool { + busAddr := os.Getenv("DBUS_SYSTEM_BUS_ADDRESS") + return !(busAddr == "" || busAddr == defaultSystemBusAddress) +} diff --git a/internal/testutils/golden.go b/internal/testutils/golden.go new file mode 100644 index 000000000..78382fdf7 --- /dev/null +++ b/internal/testutils/golden.go @@ -0,0 +1,119 @@ +package testutils + +import ( + "flag" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +var update bool + +type goldenOptions struct { + goldenPath string +} + +// GoldenOption is a supported option reference to change the golden files comparison. +type GoldenOption func(*goldenOptions) + +// WithGoldenPath overrides the default path for golden files used. +func WithGoldenPath(path string) GoldenOption { + return func(o *goldenOptions) { + if path != "" { + o.goldenPath = path + } + } +} + +// LoadWithUpdateFromGolden loads the element from a plaintext golden file. +// It will update the file if the update flag is used prior to loading it. +func LoadWithUpdateFromGolden(t *testing.T, data string, opts ...GoldenOption) string { + t.Helper() + + o := goldenOptions{ + goldenPath: GoldenPath(t), + } + + for _, opt := range opts { + opt(&o) + } + + if update { + t.Logf("updating golden file %s", o.goldenPath) + err := os.MkdirAll(filepath.Dir(o.goldenPath), 0750) + require.NoError(t, err, "Cannot create directory for updating golden files") + err = os.WriteFile(o.goldenPath, []byte(data), 0600) + require.NoError(t, err, "Cannot write golden file") + } + + want, err := os.ReadFile(o.goldenPath) + require.NoError(t, err, "Cannot load golden file") + + return string(want) +} + +// LoadWithUpdateFromGoldenYAML load the generic element from a YAML serialized golden file. +// It will update the file if the update flag is used prior to deserializing it. +func LoadWithUpdateFromGoldenYAML[E any](t *testing.T, got E, opts ...GoldenOption) E { + t.Helper() + + t.Logf("Serializing object for golden file") + data, err := yaml.Marshal(got) + require.NoError(t, err, "Cannot serialize provided object") + want := LoadWithUpdateFromGolden(t, string(data), opts...) + + var wantDeserialized E + err = yaml.Unmarshal([]byte(want), &wantDeserialized) + require.NoError(t, err, "Cannot create expanded policy objects from golden file") + + return wantDeserialized +} + +// NormalizeGoldenName returns the name of the golden file with illegal Windows +// characters replaced or removed. +func NormalizeGoldenName(t *testing.T, name string) string { + t.Helper() + + name = strings.ReplaceAll(name, `\`, "_") + name = strings.ReplaceAll(name, ":", "") + name = strings.ToLower(name) + return name +} + +// TestFamilyPath returns the path of the dir for storing fixtures and other files related to the test. +func TestFamilyPath(t *testing.T) string { + t.Helper() + + // Ensures that only the name of the parent test is used. + super, _, _ := strings.Cut(t.Name(), "/") + + return filepath.Join("testdata", super) +} + +// GoldenPath returns the golden path for the provided test. +func GoldenPath(t *testing.T) string { + t.Helper() + + path := filepath.Join(TestFamilyPath(t), "golden") + _, sub, found := strings.Cut(t.Name(), "/") + if found { + path = filepath.Join(path, NormalizeGoldenName(t, sub)) + } + + return path +} + +// InstallUpdateFlag install an update flag referenced in this package. +// The flags need to be parsed before running the tests. +func InstallUpdateFlag() { + flag.BoolVar(&update, "update", false, "update golden files") +} + +// Update returns true if the update flag was set, false otherwise. +func Update() bool { + return update +}